mirror of
https://github.com/langgenius/dify.git
synced 2026-03-23 16:57:10 +00:00
Compare commits
29 Commits
scdeng/mai
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169511e68b | ||
|
|
fdc880bc67 | ||
|
|
abda859075 | ||
|
|
dc1a68661c | ||
|
|
edb261bc90 | ||
|
|
407f5f0cde | ||
|
|
d7cafc6296 | ||
|
|
9336935295 | ||
|
|
e5e8c0711c | ||
|
|
02e13e6d05 | ||
|
|
a942d4c926 | ||
|
|
df69997d8e | ||
|
|
4ab7ba4f2e | ||
|
|
76a23deba7 | ||
|
|
25a83065d2 | ||
|
|
82b094a2d5 | ||
|
|
3c672703bc | ||
|
|
33000d1c60 | ||
|
|
2809e4cc40 | ||
|
|
3f8f1fa003 | ||
|
|
6604f8d506 | ||
|
|
368fc0bbe5 | ||
|
|
6014853d45 | ||
|
|
a71b7909fd | ||
|
|
1bf296982b | ||
|
|
2b6f761dfe | ||
|
|
6ecf89e262 | ||
|
|
e844edcf26 | ||
|
|
244f9e0c11 |
@@ -4,7 +4,7 @@ from typing import Literal
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal_with
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy import exists, func, select
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
@@ -244,27 +244,25 @@ class ChatMessageListApi(Resource):
|
||||
def get(self, app_model):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = (
|
||||
db.session.query(Conversation)
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
if args.first_id:
|
||||
first_message = (
|
||||
db.session.query(Message)
|
||||
.where(Message.conversation_id == conversation.id, Message.id == args.first_id)
|
||||
.first()
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
|
||||
)
|
||||
|
||||
if not first_message:
|
||||
raise NotFound("First message not found")
|
||||
|
||||
history_messages = (
|
||||
db.session.query(Message)
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
@@ -272,16 +270,14 @@ class ChatMessageListApi(Resource):
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
.all()
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
history_messages = (
|
||||
db.session.query(Message)
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
.all()
|
||||
)
|
||||
).all()
|
||||
|
||||
# Initialize has_more based on whether we have a full page
|
||||
if len(history_messages) == args.limit:
|
||||
@@ -326,7 +322,9 @@ class MessageFeedbackApi(Resource):
|
||||
|
||||
message_id = str(args.message_id)
|
||||
|
||||
message = db.session.query(Message).where(Message.id == message_id, Message.app_id == app_model.id).first()
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
@@ -375,7 +373,9 @@ class MessageAnnotationCountApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model):
|
||||
count = db.session.query(MessageAnnotation).where(MessageAnnotation.app_id == app_model.id).count()
|
||||
count = db.session.scalar(
|
||||
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
|
||||
)
|
||||
|
||||
return {"count": count}
|
||||
|
||||
@@ -479,7 +479,9 @@ class MessageApi(Resource):
|
||||
def get(self, app_model, message_id: str):
|
||||
message_id = str(message_id)
|
||||
|
||||
message = db.session.query(Message).where(Message.id == message_id, Message.app_id == app_model.id).first()
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
from flask import current_app, redirect, request
|
||||
@@ -112,6 +113,9 @@ class OAuthCallback(Resource):
|
||||
error_text = e.response.text
|
||||
logger.exception("An error occurred during the OAuth process with %s: %s", provider, error_text)
|
||||
return {"error": "OAuth process failed"}, 400
|
||||
except ValueError as e:
|
||||
logger.warning("OAuth error with %s", provider, exc_info=True)
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={urllib.parse.quote(str(e))}")
|
||||
|
||||
if invite_token and RegisterService.is_valid_invite_token(invite_token):
|
||||
invitation = RegisterService.get_invitation_by_token(token=invite_token)
|
||||
|
||||
@@ -181,10 +181,6 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
arize_phoenix_config: ArizeConfig | PhoenixConfig,
|
||||
):
|
||||
super().__init__(arize_phoenix_config)
|
||||
import logging
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
self.arize_phoenix_config = arize_phoenix_config
|
||||
self.tracer, self.processor = setup_tracer(arize_phoenix_config)
|
||||
self.project = arize_phoenix_config.project
|
||||
|
||||
@@ -50,7 +50,7 @@ class BuiltinTool(Tool):
|
||||
return ModelInvocationUtils.invoke(
|
||||
user_id=user_id,
|
||||
tenant_id=self.runtime.tenant_id or "",
|
||||
tool_type="builtin",
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
tool_name=self.entity.identity.name,
|
||||
prompt_messages=prompt_messages,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ class ToolLabelManager:
|
||||
db.session.add(
|
||||
ToolLabelBinding(
|
||||
tool_id=provider_id,
|
||||
tool_type=controller.provider_type.value,
|
||||
tool_type=controller.provider_type,
|
||||
label_name=label,
|
||||
)
|
||||
)
|
||||
@@ -58,7 +58,7 @@ class ToolLabelManager:
|
||||
raise ValueError("Unsupported tool type")
|
||||
stmt = select(ToolLabelBinding.label_name).where(
|
||||
ToolLabelBinding.tool_id == provider_id,
|
||||
ToolLabelBinding.tool_type == controller.provider_type.value,
|
||||
ToolLabelBinding.tool_type == controller.provider_type,
|
||||
)
|
||||
labels = db.session.scalars(stmt).all()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
from core.model_manager import ModelManager
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from dify_graph.model_runtime.entities.llm_entities import LLMResult
|
||||
from dify_graph.model_runtime.entities.message_entities import PromptMessage
|
||||
from dify_graph.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
|
||||
@@ -78,7 +79,7 @@ class ModelInvocationUtils:
|
||||
|
||||
@staticmethod
|
||||
def invoke(
|
||||
user_id: str, tenant_id: str, tool_type: str, tool_name: str, prompt_messages: list[PromptMessage]
|
||||
user_id: str, tenant_id: str, tool_type: ToolProviderType, tool_name: str, prompt_messages: list[PromptMessage]
|
||||
) -> LLMResult:
|
||||
"""
|
||||
invoke model with parameters in user's own context
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import logging
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import NotRequired
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import TypedDict
|
||||
else:
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JsonObject = dict[str, object]
|
||||
JsonObjectList = list[JsonObject]
|
||||
|
||||
@@ -30,8 +33,8 @@ class GitHubEmailRecord(TypedDict, total=False):
|
||||
class GitHubRawUserInfo(TypedDict):
|
||||
id: int | str
|
||||
login: str
|
||||
name: NotRequired[str]
|
||||
email: NotRequired[str]
|
||||
name: NotRequired[str | None]
|
||||
email: NotRequired[str | None]
|
||||
|
||||
|
||||
class GoogleRawUserInfo(TypedDict):
|
||||
@@ -127,9 +130,14 @@ class GitHubOAuth(OAuth):
|
||||
response.raise_for_status()
|
||||
user_info = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(_json_object(response))
|
||||
|
||||
email_response = httpx.get(self._EMAIL_INFO_URL, headers=headers)
|
||||
email_info = GITHUB_EMAIL_RECORDS_ADAPTER.validate_python(_json_list(email_response))
|
||||
primary_email = next((email for email in email_info if email.get("primary") is True), None)
|
||||
try:
|
||||
email_response = httpx.get(self._EMAIL_INFO_URL, headers=headers)
|
||||
email_response.raise_for_status()
|
||||
email_info = GITHUB_EMAIL_RECORDS_ADAPTER.validate_python(_json_list(email_response))
|
||||
primary_email = next((email for email in email_info if email.get("primary") is True), None)
|
||||
except (httpx.HTTPStatusError, ValidationError):
|
||||
logger.warning("Failed to retrieve email from GitHub /user/emails endpoint", exc_info=True)
|
||||
primary_email = None
|
||||
|
||||
return {**user_info, "email": primary_email.get("email", "") if primary_email else ""}
|
||||
|
||||
@@ -137,8 +145,11 @@ class GitHubOAuth(OAuth):
|
||||
payload = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(raw_info)
|
||||
email = payload.get("email")
|
||||
if not email:
|
||||
email = f"{payload['id']}+{payload['login']}@users.noreply.github.com"
|
||||
return OAuthUserInfo(id=str(payload["id"]), name=str(payload.get("name", "")), email=email)
|
||||
raise ValueError(
|
||||
'Dify currently not supports the "Keep my email addresses private" feature,'
|
||||
" please disable it and login again"
|
||||
)
|
||||
return OAuthUserInfo(id=str(payload["id"]), name=str(payload.get("name") or ""), email=email)
|
||||
|
||||
|
||||
class GoogleOAuth(OAuth):
|
||||
|
||||
@@ -66,8 +66,8 @@ class HumanInputContent(ExecutionExtraContent):
|
||||
form_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
@classmethod
|
||||
def new(cls, form_id: str, message_id: str | None) -> "HumanInputContent":
|
||||
return cls(form_id=form_id, message_id=message_id)
|
||||
def new(cls, *, workflow_run_id: str, form_id: str, message_id: str | None) -> "HumanInputContent":
|
||||
return cls(workflow_run_id=workflow_run_id, form_id=form_id, message_id=message_id)
|
||||
|
||||
form: Mapped["HumanInputForm"] = relationship(
|
||||
"HumanInputForm",
|
||||
|
||||
@@ -43,6 +43,7 @@ from .enums import (
|
||||
MessageChainType,
|
||||
MessageFileBelongsTo,
|
||||
MessageStatus,
|
||||
TagType,
|
||||
)
|
||||
from .provider_ids import GenericProviderID
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
@@ -2404,7 +2405,7 @@ class Tag(TypeBase):
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
tenant_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
type: Mapped[TagType] = mapped_column(EnumText(TagType, length=16), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -13,12 +13,16 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_bundle import ApiToolBundle
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
from core.tools.entities.tool_entities import (
|
||||
ApiProviderSchemaType,
|
||||
ToolProviderType,
|
||||
WorkflowToolParameterConfiguration,
|
||||
)
|
||||
|
||||
from .base import TypeBase
|
||||
from .engine import db
|
||||
from .model import Account, App, Tenant
|
||||
from .types import LongText, StringUUID
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.entities.mcp_provider import MCPProviderEntity
|
||||
@@ -208,7 +212,7 @@ class ToolLabelBinding(TypeBase):
|
||||
# tool id
|
||||
tool_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# tool type
|
||||
tool_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
tool_type: Mapped[ToolProviderType] = mapped_column(EnumText(ToolProviderType, length=40), nullable=False)
|
||||
# label name
|
||||
label_name: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
|
||||
@@ -386,7 +390,7 @@ class ToolModelInvoke(TypeBase):
|
||||
# provider
|
||||
provider: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# type
|
||||
tool_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
tool_type: Mapped[ToolProviderType] = mapped_column(EnumText(ToolProviderType, length=40), nullable=False)
|
||||
# tool name
|
||||
tool_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# invoke parameters
|
||||
|
||||
@@ -8,7 +8,7 @@ dependencies = [
|
||||
"arize-phoenix-otel~=0.15.0",
|
||||
"azure-identity==1.25.3",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.68",
|
||||
"boto3==1.42.73",
|
||||
"bs4~=0.0.1",
|
||||
"cachetools~=5.3.0",
|
||||
"celery~=5.6.2",
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"gevent~=25.9.1",
|
||||
"gmpy2~=2.3.0",
|
||||
"google-api-core>=2.19.1",
|
||||
"google-api-python-client==2.192.0",
|
||||
"google-api-python-client==2.193.0",
|
||||
"google-auth>=2.47.0",
|
||||
"google-auth-httplib2==0.3.0",
|
||||
"google-cloud-aiplatform>=1.123.0",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"numpy~=1.26.4",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.10.37",
|
||||
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
|
||||
"litellm==1.82.6", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
@@ -72,10 +72,10 @@ dependencies = [
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=7.3.0",
|
||||
"resend~=2.23.0",
|
||||
"sentry-sdk[flask]~=2.54.0",
|
||||
"resend~=2.26.0",
|
||||
"sentry-sdk[flask]~=2.55.0",
|
||||
"sqlalchemy~=2.0.29",
|
||||
"starlette==0.52.1",
|
||||
"starlette==1.0.0",
|
||||
"tiktoken~=0.12.0",
|
||||
"transformers~=5.3.0",
|
||||
"unstructured[docx,epub,md,ppt,pptx]~=0.21.5",
|
||||
@@ -92,7 +92,7 @@ dependencies = [
|
||||
"apscheduler>=3.11.0",
|
||||
"weave>=0.52.16",
|
||||
"fastopenapi[flask]>=0.7.0",
|
||||
"bleach~=6.2.0",
|
||||
"bleach~=6.3.0",
|
||||
]
|
||||
# Before adding new dependency, consider place it in
|
||||
# alphabet order (a-z) and suitable group.
|
||||
@@ -119,7 +119,7 @@ dev = [
|
||||
"ruff~=0.15.5",
|
||||
"pytest~=9.0.2",
|
||||
"pytest-benchmark~=5.2.3",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-env~=1.6.0",
|
||||
"pytest-mock~=3.15.1",
|
||||
"testcontainers~=4.14.1",
|
||||
@@ -203,7 +203,7 @@ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"]
|
||||
# Required by vector store clients
|
||||
############################################################
|
||||
vdb = [
|
||||
"alibabacloud_gpdb20160503~=3.8.0",
|
||||
"alibabacloud_gpdb20160503~=5.1.0",
|
||||
"alibabacloud_tea_openapi~=0.4.3",
|
||||
"chromadb==0.5.20",
|
||||
"clickhouse-connect~=0.14.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ from werkzeug.exceptions import NotFound
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
|
||||
|
||||
@@ -83,7 +84,7 @@ class TagService:
|
||||
raise ValueError("Tag name already exists")
|
||||
tag = Tag(
|
||||
name=args["name"],
|
||||
type=args["type"],
|
||||
type=TagType(args["type"]),
|
||||
created_by=current_user.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
from tests.test_containers_integration_tests.helpers.execution_extra_content import (
|
||||
create_human_input_message_fixture,
|
||||
)
|
||||
|
||||
|
||||
def test_get_by_message_ids_returns_human_input_content(db_session_with_containers):
|
||||
fixture = create_human_input_message_fixture(db_session_with_containers)
|
||||
repository = SQLAlchemyExecutionExtraContentRepository(
|
||||
session_maker=sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
)
|
||||
|
||||
results = repository.get_by_message_ids([fixture.message.id])
|
||||
|
||||
assert len(results) == 1
|
||||
assert len(results[0]) == 1
|
||||
content = results[0][0]
|
||||
assert content.submitted is True
|
||||
assert content.form_submission_data is not None
|
||||
assert content.form_submission_data.action_id == fixture.action_id
|
||||
assert content.form_submission_data.action_text == fixture.action_text
|
||||
assert content.form_submission_data.rendered_content == fixture.form.rendered_content
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Integration tests for SQLAlchemyExecutionExtraContentRepository using Testcontainers.
|
||||
|
||||
Part of #32454 — replaces the mock-based unit tests with real database interactions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, delete, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from dify_graph.nodes.human_input.entities import FormDefinition, UserAction
|
||||
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import ConversationFromSource, InvokeFrom
|
||||
from models.execution_extra_content import ExecutionExtraContent, HumanInputContent
|
||||
from models.human_input import (
|
||||
ConsoleRecipientPayload,
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
RecipientType,
|
||||
)
|
||||
from models.model import App, Conversation, Message
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TestScope:
|
||||
"""Per-test data scope used to isolate DB rows.
|
||||
|
||||
IDs are populated after flushing the base entities to the database.
|
||||
"""
|
||||
|
||||
tenant_id: str = ""
|
||||
app_id: str = ""
|
||||
user_id: str = ""
|
||||
|
||||
|
||||
def _cleanup_scope_data(session: Session, scope: _TestScope) -> None:
|
||||
"""Remove test-created DB rows for a test scope."""
|
||||
form_ids_subquery = select(HumanInputForm.id).where(
|
||||
HumanInputForm.tenant_id == scope.tenant_id,
|
||||
)
|
||||
session.execute(delete(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids_subquery)))
|
||||
session.execute(delete(HumanInputDelivery).where(HumanInputDelivery.form_id.in_(form_ids_subquery)))
|
||||
session.execute(
|
||||
delete(ExecutionExtraContent).where(
|
||||
ExecutionExtraContent.workflow_run_id.in_(
|
||||
select(HumanInputForm.workflow_run_id).where(HumanInputForm.tenant_id == scope.tenant_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
session.execute(delete(HumanInputForm).where(HumanInputForm.tenant_id == scope.tenant_id))
|
||||
session.execute(delete(Message).where(Message.app_id == scope.app_id))
|
||||
session.execute(delete(Conversation).where(Conversation.app_id == scope.app_id))
|
||||
session.execute(delete(App).where(App.id == scope.app_id))
|
||||
session.execute(delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == scope.tenant_id))
|
||||
session.execute(delete(Account).where(Account.id == scope.user_id))
|
||||
session.execute(delete(Tenant).where(Tenant.id == scope.tenant_id))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _seed_base_entities(session: Session, scope: _TestScope) -> None:
|
||||
"""Create the base tenant, account, and app needed by tests."""
|
||||
tenant = Tenant(name="Test Tenant")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
scope.tenant_id = tenant.id
|
||||
|
||||
account = Account(
|
||||
name="Test Account",
|
||||
email=f"test_{uuid4()}@example.com",
|
||||
password="hashed-password",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
scope.user_id = account.id
|
||||
|
||||
tenant_join = TenantAccountJoin(
|
||||
tenant_id=scope.tenant_id,
|
||||
account_id=scope.user_id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
current=True,
|
||||
)
|
||||
session.add(tenant_join)
|
||||
|
||||
app = App(
|
||||
tenant_id=scope.tenant_id,
|
||||
name="Test App",
|
||||
description="",
|
||||
mode="chat",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=scope.user_id,
|
||||
updated_by=scope.user_id,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
scope.app_id = app.id
|
||||
|
||||
|
||||
def _create_conversation(session: Session, scope: _TestScope) -> Conversation:
|
||||
conversation = Conversation(
|
||||
app_id=scope.app_id,
|
||||
mode="chat",
|
||||
name="Test Conversation",
|
||||
summary="",
|
||||
introduction="",
|
||||
system_instruction="",
|
||||
status="normal",
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=scope.user_id,
|
||||
from_end_user_id=None,
|
||||
)
|
||||
conversation.inputs = {}
|
||||
session.add(conversation)
|
||||
session.flush()
|
||||
return conversation
|
||||
|
||||
|
||||
def _create_message(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
conversation_id: str,
|
||||
workflow_run_id: str,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
app_id=scope.app_id,
|
||||
conversation_id=conversation_id,
|
||||
inputs={},
|
||||
query="test query",
|
||||
message={"messages": []},
|
||||
answer="test answer",
|
||||
message_tokens=50,
|
||||
message_unit_price=Decimal("0.001"),
|
||||
answer_tokens=80,
|
||||
answer_unit_price=Decimal("0.001"),
|
||||
provider_response_latency=0.5,
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=scope.user_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
session.add(message)
|
||||
session.flush()
|
||||
return message
|
||||
|
||||
|
||||
def _create_submitted_form(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
action_id: str = "approve",
|
||||
action_title: str = "Approve",
|
||||
node_title: str = "Approval",
|
||||
) -> HumanInputForm:
|
||||
expiration_time = datetime.utcnow() + timedelta(days=1)
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id=action_id, title=action_title)],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
node_title=node_title,
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id="node-id",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content=f"Rendered {action_title}",
|
||||
status=HumanInputFormStatus.SUBMITTED,
|
||||
expiration_time=expiration_time,
|
||||
selected_action_id=action_id,
|
||||
)
|
||||
session.add(form)
|
||||
session.flush()
|
||||
return form
|
||||
|
||||
|
||||
def _create_waiting_form(
|
||||
session: Session,
|
||||
scope: _TestScope,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
default_values: dict | None = None,
|
||||
) -> HumanInputForm:
|
||||
expiration_time = datetime.utcnow() + timedelta(days=1)
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values=default_values or {"name": "John"},
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
node_id="node-id",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="Rendered block",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
session.add(form)
|
||||
session.flush()
|
||||
return form
|
||||
|
||||
|
||||
def _create_human_input_content(
|
||||
session: Session,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
message_id: str,
|
||||
form_id: str,
|
||||
) -> HumanInputContent:
|
||||
content = HumanInputContent.new(
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=message_id,
|
||||
form_id=form_id,
|
||||
)
|
||||
session.add(content)
|
||||
return content
|
||||
|
||||
|
||||
def _create_recipient(
|
||||
session: Session,
|
||||
*,
|
||||
form_id: str,
|
||||
delivery_id: str,
|
||||
recipient_type: RecipientType = RecipientType.CONSOLE,
|
||||
access_token: str = "token-1",
|
||||
) -> HumanInputFormRecipient:
|
||||
payload = ConsoleRecipientPayload(account_id=None)
|
||||
recipient = HumanInputFormRecipient(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
recipient_type=recipient_type,
|
||||
recipient_payload=payload.model_dump_json(),
|
||||
access_token=access_token,
|
||||
)
|
||||
session.add(recipient)
|
||||
return recipient
|
||||
|
||||
|
||||
def _create_delivery(session: Session, *, form_id: str) -> HumanInputDelivery:
|
||||
from dify_graph.nodes.human_input.enums import DeliveryMethodType
|
||||
from models.human_input import ConsoleDeliveryPayload
|
||||
|
||||
delivery = HumanInputDelivery(
|
||||
form_id=form_id,
|
||||
delivery_method_type=DeliveryMethodType.WEBAPP,
|
||||
channel_payload=ConsoleDeliveryPayload().model_dump_json(),
|
||||
)
|
||||
session.add(delivery)
|
||||
session.flush()
|
||||
return delivery
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(db_session_with_containers: Session) -> SQLAlchemyExecutionExtraContentRepository:
|
||||
"""Build a repository backed by the testcontainers database engine."""
|
||||
engine = db_session_with_containers.get_bind()
|
||||
assert isinstance(engine, Engine)
|
||||
return SQLAlchemyExecutionExtraContentRepository(sessionmaker(bind=engine, expire_on_commit=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_scope(db_session_with_containers: Session) -> Generator[_TestScope]:
|
||||
"""Provide an isolated scope and clean related data after each test."""
|
||||
scope = _TestScope()
|
||||
_seed_base_entities(db_session_with_containers, scope)
|
||||
db_session_with_containers.commit()
|
||||
yield scope
|
||||
_cleanup_scope_data(db_session_with_containers, scope)
|
||||
|
||||
|
||||
class TestGetByMessageIds:
|
||||
"""Tests for SQLAlchemyExecutionExtraContentRepository.get_by_message_ids."""
|
||||
|
||||
def test_groups_contents_by_message(
|
||||
self,
|
||||
db_session_with_containers: Session,
|
||||
repository: SQLAlchemyExecutionExtraContentRepository,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Submitted forms are correctly mapped and grouped by message ID."""
|
||||
workflow_run_id = str(uuid4())
|
||||
conversation = _create_conversation(db_session_with_containers, test_scope)
|
||||
msg1 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
|
||||
msg2 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
|
||||
|
||||
form = _create_submitted_form(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
workflow_run_id=workflow_run_id,
|
||||
action_id="approve",
|
||||
action_title="Approve",
|
||||
)
|
||||
_create_human_input_content(
|
||||
db_session_with_containers,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=msg1.id,
|
||||
form_id=form.id,
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = repository.get_by_message_ids([msg1.id, msg2.id])
|
||||
|
||||
assert len(result) == 2
|
||||
# msg1 has one submitted content
|
||||
assert len(result[0]) == 1
|
||||
content = result[0][0]
|
||||
assert content.submitted is True
|
||||
assert content.workflow_run_id == workflow_run_id
|
||||
assert content.form_submission_data is not None
|
||||
assert content.form_submission_data.action_id == "approve"
|
||||
assert content.form_submission_data.action_text == "Approve"
|
||||
assert content.form_submission_data.rendered_content == "Rendered Approve"
|
||||
assert content.form_submission_data.node_id == "node-id"
|
||||
assert content.form_submission_data.node_title == "Approval"
|
||||
# msg2 has no content
|
||||
assert result[1] == []
|
||||
|
||||
def test_returns_unsubmitted_form_definition(
|
||||
self,
|
||||
db_session_with_containers: Session,
|
||||
repository: SQLAlchemyExecutionExtraContentRepository,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Waiting forms return full form_definition with resolved token and defaults."""
|
||||
workflow_run_id = str(uuid4())
|
||||
conversation = _create_conversation(db_session_with_containers, test_scope)
|
||||
msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
|
||||
|
||||
form = _create_waiting_form(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
workflow_run_id=workflow_run_id,
|
||||
default_values={"name": "John"},
|
||||
)
|
||||
delivery = _create_delivery(db_session_with_containers, form_id=form.id)
|
||||
_create_recipient(
|
||||
db_session_with_containers,
|
||||
form_id=form.id,
|
||||
delivery_id=delivery.id,
|
||||
access_token="token-1",
|
||||
)
|
||||
_create_human_input_content(
|
||||
db_session_with_containers,
|
||||
workflow_run_id=workflow_run_id,
|
||||
message_id=msg.id,
|
||||
form_id=form.id,
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = repository.get_by_message_ids([msg.id])
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 1
|
||||
domain_content = result[0][0]
|
||||
assert domain_content.submitted is False
|
||||
assert domain_content.workflow_run_id == workflow_run_id
|
||||
assert domain_content.form_definition is not None
|
||||
form_def = domain_content.form_definition
|
||||
assert form_def.form_id == form.id
|
||||
assert form_def.node_id == "node-id"
|
||||
assert form_def.node_title == "Approval"
|
||||
assert form_def.form_content == "Rendered block"
|
||||
assert form_def.display_in_ui is True
|
||||
assert form_def.form_token == "token-1"
|
||||
assert form_def.resolved_default_values == {"name": "John"}
|
||||
assert form_def.expiration_time == int(form.expiration_time.timestamp())
|
||||
|
||||
def test_empty_message_ids_returns_empty_list(
|
||||
self,
|
||||
repository: SQLAlchemyExecutionExtraContentRepository,
|
||||
) -> None:
|
||||
"""Passing no message IDs returns an empty list without hitting the DB."""
|
||||
result = repository.get_by_message_ids([])
|
||||
assert result == []
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Testcontainers integration tests for AttachmentService."""
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import services.attachment_service as attachment_service_module
|
||||
from extensions.ext_database import db
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from services.attachment_service import AttachmentService
|
||||
|
||||
|
||||
class TestAttachmentService:
|
||||
def _create_upload_file(self, db_session_with_containers, *, tenant_id: str | None = None) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id or str(uuid4()),
|
||||
storage_type=StorageType.OPENDAL,
|
||||
key=f"upload/{uuid4()}.txt",
|
||||
name="test-file.txt",
|
||||
size=100,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
created_at=datetime.now(UTC),
|
||||
used=False,
|
||||
)
|
||||
db_session_with_containers.add(upload_file)
|
||||
db_session_with_containers.commit()
|
||||
return upload_file
|
||||
|
||||
def test_should_initialize_with_sessionmaker(self):
|
||||
session_factory = sessionmaker()
|
||||
|
||||
service = AttachmentService(session_factory=session_factory)
|
||||
|
||||
assert service._session_maker is session_factory
|
||||
|
||||
def test_should_initialize_with_engine(self):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
service = AttachmentService(session_factory=engine)
|
||||
session = service._session_maker()
|
||||
try:
|
||||
assert session.bind == engine
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.parametrize("invalid_session_factory", [None, "not-a-session-factory", 1])
|
||||
def test_should_raise_assertion_error_for_invalid_session_factory(self, invalid_session_factory):
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
AttachmentService(session_factory=invalid_session_factory)
|
||||
|
||||
def test_should_return_base64_when_file_exists(self, db_session_with_containers):
|
||||
upload_file = self._create_upload_file(db_session_with_containers)
|
||||
service = AttachmentService(session_factory=sessionmaker(bind=db.engine))
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once", return_value=b"binary-content") as mock_load:
|
||||
result = service.get_file_base64(upload_file.id)
|
||||
|
||||
assert result == base64.b64encode(b"binary-content").decode()
|
||||
mock_load.assert_called_once_with(upload_file.key)
|
||||
|
||||
def test_should_raise_not_found_when_file_missing(self, db_session_with_containers):
|
||||
service = AttachmentService(session_factory=sessionmaker(bind=db.engine))
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once") as mock_load:
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
service.get_file_base64(str(uuid4()))
|
||||
|
||||
mock_load.assert_not_called()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Testcontainers integration tests for ConversationVariableUpdater."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from dify_graph.variables import StringVariable
|
||||
from extensions.ext_database import db
|
||||
from models.workflow import ConversationVariable
|
||||
from services.conversation_variable_updater import ConversationVariableNotFoundError, ConversationVariableUpdater
|
||||
|
||||
|
||||
class TestConversationVariableUpdater:
|
||||
def _create_conversation_variable(
|
||||
self, db_session_with_containers, *, conversation_id: str, variable: StringVariable, app_id: str | None = None
|
||||
) -> ConversationVariable:
|
||||
row = ConversationVariable(
|
||||
id=variable.id,
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id or str(uuid4()),
|
||||
data=variable.model_dump_json(),
|
||||
)
|
||||
db_session_with_containers.add(row)
|
||||
db_session_with_containers.commit()
|
||||
return row
|
||||
|
||||
def test_should_update_conversation_variable_data_and_commit(self, db_session_with_containers):
|
||||
conversation_id = str(uuid4())
|
||||
variable = StringVariable(id=str(uuid4()), name="topic", value="old value")
|
||||
self._create_conversation_variable(
|
||||
db_session_with_containers, conversation_id=conversation_id, variable=variable
|
||||
)
|
||||
|
||||
updated_variable = StringVariable(id=variable.id, name="topic", value="new value")
|
||||
updater = ConversationVariableUpdater(sessionmaker(bind=db.engine))
|
||||
|
||||
updater.update(conversation_id=conversation_id, variable=updated_variable)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
row = db_session_with_containers.get(ConversationVariable, (variable.id, conversation_id))
|
||||
assert row is not None
|
||||
assert row.data == updated_variable.model_dump_json()
|
||||
|
||||
def test_should_raise_not_found_when_variable_missing(self, db_session_with_containers):
|
||||
conversation_id = str(uuid4())
|
||||
variable = StringVariable(id=str(uuid4()), name="topic", value="value")
|
||||
updater = ConversationVariableUpdater(sessionmaker(bind=db.engine))
|
||||
|
||||
with pytest.raises(ConversationVariableNotFoundError, match="conversation variable not found in the database"):
|
||||
updater.update(conversation_id=conversation_id, variable=variable)
|
||||
|
||||
def test_should_do_nothing_when_flush_is_called(self, db_session_with_containers):
|
||||
updater = ConversationVariableUpdater(sessionmaker(bind=db.engine))
|
||||
|
||||
result = updater.flush()
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Testcontainers integration tests for CreditPoolService."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from core.errors.error import QuotaExceededError
|
||||
from models import TenantCreditPool
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
|
||||
class TestCreditPoolService:
|
||||
def _create_tenant_id(self) -> str:
|
||||
return str(uuid4())
|
||||
|
||||
def test_create_default_pool(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
assert isinstance(pool, TenantCreditPool)
|
||||
assert pool.tenant_id == tenant_id
|
||||
assert pool.pool_type == "trial"
|
||||
assert pool.quota_used == 0
|
||||
assert pool.quota_limit > 0
|
||||
|
||||
def test_get_pool_returns_pool_when_exists(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
result = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial")
|
||||
|
||||
assert result is not None
|
||||
assert result.tenant_id == tenant_id
|
||||
assert result.pool_type == "trial"
|
||||
|
||||
def test_get_pool_returns_none_when_not_exists(self, db_session_with_containers):
|
||||
result = CreditPoolService.get_pool(tenant_id=self._create_tenant_id(), pool_type="trial")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_check_credits_available_returns_false_when_no_pool(self, db_session_with_containers):
|
||||
result = CreditPoolService.check_credits_available(tenant_id=self._create_tenant_id(), credits_required=10)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_check_credits_available_returns_true_when_sufficient(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
result = CreditPoolService.check_credits_available(tenant_id=tenant_id, credits_required=10)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_check_credits_available_returns_false_when_insufficient(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
# Exhaust credits
|
||||
pool.quota_used = pool.quota_limit
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = CreditPoolService.check_credits_available(tenant_id=tenant_id, credits_required=1)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_check_and_deduct_credits_raises_when_no_pool(self, db_session_with_containers):
|
||||
with pytest.raises(QuotaExceededError, match="Credit pool not found"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=self._create_tenant_id(), credits_required=10)
|
||||
|
||||
def test_check_and_deduct_credits_raises_when_no_remaining(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
pool.quota_used = pool.quota_limit
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with pytest.raises(QuotaExceededError, match="No credits remaining"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=10)
|
||||
|
||||
def test_check_and_deduct_credits_deducts_required_amount(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
CreditPoolService.create_default_pool(tenant_id)
|
||||
credits_required = 10
|
||||
|
||||
result = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=credits_required)
|
||||
|
||||
assert result == credits_required
|
||||
db_session_with_containers.expire_all()
|
||||
pool = CreditPoolService.get_pool(tenant_id=tenant_id)
|
||||
assert pool.quota_used == credits_required
|
||||
|
||||
def test_check_and_deduct_credits_caps_at_remaining(self, db_session_with_containers):
|
||||
tenant_id = self._create_tenant_id()
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
remaining = 5
|
||||
pool.quota_used = pool.quota_limit - remaining
|
||||
db_session_with_containers.commit()
|
||||
|
||||
result = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=200)
|
||||
|
||||
assert result == remaining
|
||||
db_session_with_containers.expire_all()
|
||||
updated_pool = CreditPoolService.get_pool(tenant_id=tenant_id)
|
||||
assert updated_pool.quota_used == pool.quota_limit
|
||||
@@ -397,6 +397,68 @@ class TestDatasetPermissionServiceClearPartialMemberList:
|
||||
class TestDatasetServiceCheckDatasetPermission:
|
||||
"""Verify dataset access checks against persisted partial-member permissions."""
|
||||
|
||||
def test_check_dataset_permission_different_tenant_should_fail(self, db_session_with_containers):
|
||||
"""Test that users from different tenants cannot access dataset."""
|
||||
owner, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER)
|
||||
other_user, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, owner.id, permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other_user)
|
||||
|
||||
def test_check_dataset_permission_owner_can_access_any_dataset(self, db_session_with_containers):
|
||||
"""Test that tenant owners can access any dataset regardless of permission level."""
|
||||
owner, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER)
|
||||
creator, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(
|
||||
role=TenantAccountRole.NORMAL, tenant=tenant
|
||||
)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, owner)
|
||||
|
||||
def test_check_dataset_permission_only_me_creator_can_access(self, db_session_with_containers):
|
||||
"""Test ONLY_ME permission allows only the dataset creator to access."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
def test_check_dataset_permission_only_me_others_cannot_access(self, db_session_with_containers):
|
||||
"""Test ONLY_ME permission denies access to non-creators."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
|
||||
other, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(
|
||||
role=TenantAccountRole.NORMAL, tenant=tenant
|
||||
)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, other)
|
||||
|
||||
def test_check_dataset_permission_all_team_allows_access(self, db_session_with_containers):
|
||||
"""Test ALL_TEAM permission allows any team member to access the dataset."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
|
||||
member, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(
|
||||
role=TenantAccountRole.NORMAL, tenant=tenant
|
||||
)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, member)
|
||||
|
||||
def test_check_dataset_permission_partial_members_with_permission_success(self, db_session_with_containers):
|
||||
"""
|
||||
Test that user with explicit permission can access partial_members dataset.
|
||||
@@ -443,6 +505,16 @@ class TestDatasetServiceCheckDatasetPermission:
|
||||
with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"):
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
def test_check_dataset_permission_partial_team_creator_can_access(self, db_session_with_containers):
|
||||
"""Test PARTIAL_TEAM permission allows creator to access without explicit permission."""
|
||||
creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR)
|
||||
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset(
|
||||
tenant.id, creator.id, permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, creator)
|
||||
|
||||
|
||||
class TestDatasetServiceCheckDatasetOperatorPermission:
|
||||
"""Verify operator permission checks against persisted partial-member permissions."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from werkzeug.exceptions import NotFound
|
||||
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from models.enums import DataSourceType
|
||||
from models.enums import DataSourceType, TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from services.tag_service import TagService
|
||||
|
||||
@@ -547,7 +547,7 @@ class TestTagService:
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "python_tag"
|
||||
assert result[0].type == "app"
|
||||
assert result[0].type == TagType.APP
|
||||
assert result[0].tenant_id == tenant.id
|
||||
|
||||
def test_get_tag_by_tag_name_no_matches(
|
||||
@@ -638,7 +638,7 @@ class TestTagService:
|
||||
|
||||
# Verify all tags are returned
|
||||
for tag in result:
|
||||
assert tag.type == "app"
|
||||
assert tag.type == TagType.APP
|
||||
assert tag.tenant_id == tenant.id
|
||||
assert tag.id in [t.id for t in tags]
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Testcontainers integration tests for WorkflowService.delete_workflow."""
|
||||
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from models.model import App
|
||||
from models.tools import WorkflowToolProvider
|
||||
from models.workflow import Workflow
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
|
||||
|
||||
class TestWorkflowDeletion:
|
||||
def _create_tenant_and_account(self, session: Session) -> tuple[Tenant, Account]:
|
||||
tenant = Tenant(name=f"Tenant {uuid4()}")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
|
||||
account = Account(
|
||||
name=f"Account {uuid4()}",
|
||||
email=f"wf_del_{uuid4()}@example.com",
|
||||
password="hashed",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role="owner",
|
||||
current=True,
|
||||
)
|
||||
session.add(join)
|
||||
session.flush()
|
||||
return tenant, account
|
||||
|
||||
def _create_app(self, session: Session, *, tenant: Tenant, account: Account, workflow_id: str | None = None) -> App:
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name=f"App {uuid4()}",
|
||||
description="",
|
||||
mode="workflow",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
return app
|
||||
|
||||
def _create_workflow(
|
||||
self, session: Session, *, tenant: Tenant, app: App, account: Account, version: str = "1.0"
|
||||
) -> Workflow:
|
||||
workflow = Workflow(
|
||||
id=str(uuid4()),
|
||||
tenant_id=tenant.id,
|
||||
app_id=app.id,
|
||||
type="workflow",
|
||||
version=version,
|
||||
graph=json.dumps({"nodes": [], "edges": []}),
|
||||
_features=json.dumps({}),
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
session.add(workflow)
|
||||
session.flush()
|
||||
return workflow
|
||||
|
||||
def _create_tool_provider(
|
||||
self, session: Session, *, tenant: Tenant, app: App, account: Account, version: str
|
||||
) -> WorkflowToolProvider:
|
||||
provider = WorkflowToolProvider(
|
||||
name=f"tool-{uuid4()}",
|
||||
label=f"Tool {uuid4()}",
|
||||
icon="wrench",
|
||||
app_id=app.id,
|
||||
version=version,
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
description="test tool provider",
|
||||
)
|
||||
session.add(provider)
|
||||
session.flush()
|
||||
return provider
|
||||
|
||||
def test_delete_workflow_success(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0"
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
workflow_id = workflow.id
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
result = service.delete_workflow(
|
||||
session=db_session_with_containers, workflow_id=workflow_id, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
assert result is True
|
||||
db_session_with_containers.expire_all()
|
||||
assert db_session_with_containers.get(Workflow, workflow_id) is None
|
||||
|
||||
def test_delete_draft_workflow_raises_error(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="draft"
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
with pytest.raises(DraftWorkflowDeletionError):
|
||||
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
|
||||
|
||||
def test_delete_workflow_in_use_by_app_raises_error(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0"
|
||||
)
|
||||
# Point app to this workflow
|
||||
app.workflow_id = workflow.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
with pytest.raises(WorkflowInUseError, match="currently in use by app"):
|
||||
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
|
||||
|
||||
def test_delete_workflow_published_as_tool_raises_error(self, db_session_with_containers):
|
||||
tenant, account = self._create_tenant_and_account(db_session_with_containers)
|
||||
app = self._create_app(db_session_with_containers, tenant=tenant, account=account)
|
||||
workflow = self._create_workflow(
|
||||
db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0"
|
||||
)
|
||||
self._create_tool_provider(db_session_with_containers, tenant=tenant, app=app, account=account, version="1.0")
|
||||
db_session_with_containers.commit()
|
||||
|
||||
service = WorkflowService(sessionmaker(bind=db.engine))
|
||||
with pytest.raises(WorkflowInUseError, match="published as a tool"):
|
||||
service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id)
|
||||
@@ -170,7 +170,7 @@ class TestMessageEndpoints:
|
||||
mock_app_model,
|
||||
qs={"conversation_id": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.data_query.where.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
api.get(**v_args)
|
||||
@@ -198,11 +198,11 @@ class TestMessageEndpoints:
|
||||
mock_msg.message = {}
|
||||
mock_msg.message_metadata_dict = {}
|
||||
|
||||
# mock returns
|
||||
q_mock = mock_db.data_query
|
||||
q_mock.where.return_value.first.side_effect = [mock_conv]
|
||||
q_mock.where.return_value.order_by.return_value.limit.return_value.all.return_value = [mock_msg]
|
||||
mock_db.session.scalar.return_value = False
|
||||
# scalar() is called twice: first for conversation lookup, second for has_more check
|
||||
mock_db.session.scalar.side_effect = [mock_conv, False]
|
||||
scalars_result = MagicMock()
|
||||
scalars_result.all.return_value = [mock_msg]
|
||||
mock_db.session.scalars.return_value = scalars_result
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp["limit"] == 1
|
||||
@@ -219,7 +219,7 @@ class TestMessageEndpoints:
|
||||
mock_app_model,
|
||||
payload={"message_id": "123e4567-e89b-12d3-a456-426614174000"},
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.data_query.where.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound):
|
||||
api.post(**v_args)
|
||||
@@ -231,7 +231,7 @@ class TestMessageEndpoints:
|
||||
) as (api, mock_db, v_args):
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.admin_feedback = None
|
||||
mock_db.data_query.where.return_value.first.return_value = mock_msg
|
||||
mock_db.session.scalar.return_value = mock_msg
|
||||
|
||||
resp = api.post(**v_args)
|
||||
assert resp == {"result": "success"}
|
||||
@@ -240,7 +240,7 @@ class TestMessageEndpoints:
|
||||
with setup_test_context(
|
||||
app, MessageAnnotationCountApi, "/apps/app_123/annotations/count", "GET", mock_account, mock_app_model
|
||||
) as (api, mock_db, v_args):
|
||||
mock_db.data_query.where.return_value.count.return_value = 5
|
||||
mock_db.session.scalar.return_value = 5
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp == {"count": 5}
|
||||
@@ -314,7 +314,7 @@ class TestMessageEndpoints:
|
||||
mock_msg.message = {}
|
||||
mock_msg.message_metadata_dict = {}
|
||||
|
||||
mock_db.data_query.where.return_value.first.return_value = mock_msg
|
||||
mock_db.session.scalar.return_value = mock_msg
|
||||
|
||||
resp = api.get(**v_args)
|
||||
assert resp["id"] == "msg_123"
|
||||
|
||||
@@ -11,6 +11,7 @@ from controllers.console.tag.tags import (
|
||||
TagListApi,
|
||||
TagUpdateDeleteApi,
|
||||
)
|
||||
from models.enums import TagType
|
||||
|
||||
|
||||
def unwrap(func):
|
||||
@@ -52,7 +53,7 @@ def tag():
|
||||
tag = MagicMock()
|
||||
tag.id = "tag-1"
|
||||
tag.name = "test-tag"
|
||||
tag.type = "knowledge"
|
||||
tag.type = TagType.KNOWLEDGE
|
||||
return tag
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from controllers.service_api.dataset.dataset import (
|
||||
from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
|
||||
from models.account import Account
|
||||
from models.dataset import DatasetPermissionEnum
|
||||
from models.enums import TagType
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
from services.tag_service import TagService
|
||||
|
||||
@@ -277,7 +278,7 @@ class TestDatasetTagsApi:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = "tag_1"
|
||||
mock_tag.name = "Test Tag"
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag.binding_count = "0" # Required for Pydantic validation - must be string
|
||||
mock_tag_service.get_tags.return_value = [mock_tag]
|
||||
|
||||
@@ -316,7 +317,7 @@ class TestDatasetTagsApi:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = "new_tag_1"
|
||||
mock_tag.name = "New Tag"
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag_service.save_tags.return_value = mock_tag
|
||||
mock_service_api_ns.payload = {"name": "New Tag"}
|
||||
|
||||
@@ -378,7 +379,7 @@ class TestDatasetTagsApi:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = "tag_1"
|
||||
mock_tag.name = "Updated Tag"
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_tag.binding_count = "5"
|
||||
mock_tag_service.update_tags.return_value = mock_tag
|
||||
mock_tag_service.get_tag_binding_count.return_value = 5
|
||||
@@ -866,7 +867,7 @@ class TestTagService:
|
||||
mock_tag = Mock()
|
||||
mock_tag.id = str(uuid.uuid4())
|
||||
mock_tag.name = "New Tag"
|
||||
mock_tag.type = "knowledge"
|
||||
mock_tag.type = TagType.KNOWLEDGE
|
||||
mock_save.return_value = mock_tag
|
||||
|
||||
result = TagService.save_tags({"name": "New Tag", "type": "knowledge"})
|
||||
|
||||
@@ -95,13 +95,11 @@ class TestGitHubOAuth(BaseOAuthTest):
|
||||
],
|
||||
"primary@example.com",
|
||||
),
|
||||
# User with no emails - fallback to noreply
|
||||
({"id": 12345, "login": "testuser", "name": "Test User"}, [], "12345+testuser@users.noreply.github.com"),
|
||||
# User with only secondary email - fallback to noreply
|
||||
# User with private email (null email and name from API)
|
||||
(
|
||||
{"id": 12345, "login": "testuser", "name": "Test User"},
|
||||
[{"email": "secondary@example.com", "primary": False}],
|
||||
"12345+testuser@users.noreply.github.com",
|
||||
{"id": 12345, "login": "testuser", "name": None, "email": None},
|
||||
[{"email": "primary@example.com", "primary": True}],
|
||||
"primary@example.com",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -118,9 +116,54 @@ class TestGitHubOAuth(BaseOAuthTest):
|
||||
user_info = oauth.get_user_info("test_token")
|
||||
|
||||
assert user_info.id == str(user_data["id"])
|
||||
assert user_info.name == user_data["name"]
|
||||
assert user_info.name == (user_data["name"] or "")
|
||||
assert user_info.email == expected_email
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_data", "email_data"),
|
||||
[
|
||||
# User with no emails
|
||||
({"id": 12345, "login": "testuser", "name": "Test User"}, []),
|
||||
# User with only secondary email
|
||||
(
|
||||
{"id": 12345, "login": "testuser", "name": "Test User"},
|
||||
[{"email": "secondary@example.com", "primary": False}],
|
||||
),
|
||||
# User with private email and no primary in emails endpoint
|
||||
(
|
||||
{"id": 12345, "login": "testuser", "name": None, "email": None},
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch("httpx.get", autospec=True)
|
||||
def test_should_raise_error_when_no_primary_email(self, mock_get, oauth, user_data, email_data):
|
||||
user_response = MagicMock()
|
||||
user_response.json.return_value = user_data
|
||||
|
||||
email_response = MagicMock()
|
||||
email_response.json.return_value = email_data
|
||||
|
||||
mock_get.side_effect = [user_response, email_response]
|
||||
|
||||
with pytest.raises(ValueError, match="Keep my email addresses private"):
|
||||
oauth.get_user_info("test_token")
|
||||
|
||||
@patch("httpx.get", autospec=True)
|
||||
def test_should_raise_error_when_email_endpoint_fails(self, mock_get, oauth):
|
||||
user_response = MagicMock()
|
||||
user_response.json.return_value = {"id": 12345, "login": "testuser", "name": "Test User"}
|
||||
|
||||
email_response = MagicMock()
|
||||
email_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Forbidden", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
|
||||
mock_get.side_effect = [user_response, email_response]
|
||||
|
||||
with pytest.raises(ValueError, match="Keep my email addresses private"):
|
||||
oauth.get_user_info("test_token")
|
||||
|
||||
@patch("httpx.get", autospec=True)
|
||||
def test_should_handle_network_errors(self, mock_get, oauth):
|
||||
mock_get.side_effect = httpx.RequestError("Network error")
|
||||
|
||||
@@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolProviderType
|
||||
from models.tools import (
|
||||
ApiToolProvider,
|
||||
BuiltinToolProvider,
|
||||
@@ -631,7 +631,7 @@ class TestToolLabelBinding:
|
||||
"""Test creating a tool label binding."""
|
||||
# Arrange
|
||||
tool_id = "google.search"
|
||||
tool_type = "builtin"
|
||||
tool_type = ToolProviderType.BUILT_IN
|
||||
label_name = "search"
|
||||
|
||||
# Act
|
||||
@@ -655,7 +655,7 @@ class TestToolLabelBinding:
|
||||
# Act
|
||||
label_binding = ToolLabelBinding(
|
||||
tool_id=tool_id,
|
||||
tool_type="builtin",
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
label_name=label_name,
|
||||
)
|
||||
|
||||
@@ -667,7 +667,7 @@ class TestToolLabelBinding:
|
||||
"""Test multiple labels can be bound to the same tool."""
|
||||
# Arrange
|
||||
tool_id = "google.search"
|
||||
tool_type = "builtin"
|
||||
tool_type = ToolProviderType.BUILT_IN
|
||||
|
||||
# Act
|
||||
binding1 = ToolLabelBinding(
|
||||
@@ -688,7 +688,7 @@ class TestToolLabelBinding:
|
||||
def test_tool_label_binding_different_tool_types(self):
|
||||
"""Test label bindings for different tool types."""
|
||||
# Arrange
|
||||
tool_types = ["builtin", "api", "workflow"]
|
||||
tool_types = [ToolProviderType.BUILT_IN, ToolProviderType.API, ToolProviderType.WORKFLOW]
|
||||
|
||||
# Act & Assert
|
||||
for tool_type in tool_types:
|
||||
@@ -951,12 +951,12 @@ class TestToolProviderRelationships:
|
||||
# Act
|
||||
binding1 = ToolLabelBinding(
|
||||
tool_id=tool_id,
|
||||
tool_type="builtin",
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
label_name="search",
|
||||
)
|
||||
binding2 = ToolLabelBinding(
|
||||
tool_id=tool_id,
|
||||
tool_type="builtin",
|
||||
tool_type=ToolProviderType.BUILT_IN,
|
||||
label_name="web",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from core.entities.execution_extra_content import HumanInputContent as HumanInputContentDomain
|
||||
from core.entities.execution_extra_content import HumanInputFormSubmissionData
|
||||
from dify_graph.nodes.human_input.entities import (
|
||||
FormDefinition,
|
||||
UserAction,
|
||||
)
|
||||
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
|
||||
from models.execution_extra_content import HumanInputContent as HumanInputContentModel
|
||||
from models.human_input import ConsoleRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, values: Sequence[HumanInputContentModel]):
|
||||
self._values = list(values)
|
||||
|
||||
def all(self) -> list[HumanInputContentModel]:
|
||||
return list(self._values)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, values: Sequence[Sequence[object]]):
|
||||
self._values = list(values)
|
||||
|
||||
def scalars(self, _stmt):
|
||||
if not self._values:
|
||||
return _FakeScalarResult([])
|
||||
return _FakeScalarResult(self._values.pop(0))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSessionMaker:
|
||||
session: _FakeSession
|
||||
|
||||
def __call__(self) -> _FakeSession:
|
||||
return self.session
|
||||
|
||||
|
||||
def _build_form(action_id: str, action_title: str, rendered_content: str) -> HumanInputForm:
|
||||
expiration_time = datetime.now(UTC) + timedelta(days=1)
|
||||
definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id=action_id, title=action_title)],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
id=f"form-{action_id}",
|
||||
tenant_id="tenant-id",
|
||||
app_id="app-id",
|
||||
workflow_run_id="workflow-run",
|
||||
node_id="node-id",
|
||||
form_definition=definition.model_dump_json(),
|
||||
rendered_content=rendered_content,
|
||||
status=HumanInputFormStatus.SUBMITTED,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
form.selected_action_id = action_id
|
||||
return form
|
||||
|
||||
|
||||
def _build_content(message_id: str, action_id: str, action_title: str) -> HumanInputContentModel:
|
||||
form = _build_form(
|
||||
action_id=action_id,
|
||||
action_title=action_title,
|
||||
rendered_content=f"Rendered {action_title}",
|
||||
)
|
||||
content = HumanInputContentModel(
|
||||
id=f"content-{message_id}",
|
||||
form_id=form.id,
|
||||
message_id=message_id,
|
||||
workflow_run_id=form.workflow_run_id,
|
||||
)
|
||||
content.form = form
|
||||
return content
|
||||
|
||||
|
||||
def test_get_by_message_ids_groups_contents_by_message() -> None:
|
||||
message_ids = ["msg-1", "msg-2"]
|
||||
contents = [_build_content("msg-1", "approve", "Approve")]
|
||||
repository = SQLAlchemyExecutionExtraContentRepository(
|
||||
session_maker=_FakeSessionMaker(session=_FakeSession(values=[contents, []]))
|
||||
)
|
||||
|
||||
result = repository.get_by_message_ids(message_ids)
|
||||
|
||||
assert len(result) == 2
|
||||
assert [content.model_dump(mode="json", exclude_none=True) for content in result[0]] == [
|
||||
HumanInputContentDomain(
|
||||
workflow_run_id="workflow-run",
|
||||
submitted=True,
|
||||
form_submission_data=HumanInputFormSubmissionData(
|
||||
node_id="node-id",
|
||||
node_title="Approval",
|
||||
rendered_content="Rendered Approve",
|
||||
action_id="approve",
|
||||
action_text="Approve",
|
||||
),
|
||||
).model_dump(mode="json", exclude_none=True)
|
||||
]
|
||||
assert result[1] == []
|
||||
|
||||
|
||||
def test_get_by_message_ids_returns_unsubmitted_form_definition() -> None:
|
||||
expiration_time = datetime.now(UTC) + timedelta(days=1)
|
||||
definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values={"name": "John"},
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
id="form-1",
|
||||
tenant_id="tenant-id",
|
||||
app_id="app-id",
|
||||
workflow_run_id="workflow-run",
|
||||
node_id="node-id",
|
||||
form_definition=definition.model_dump_json(),
|
||||
rendered_content="Rendered block",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
content = HumanInputContentModel(
|
||||
id="content-msg-1",
|
||||
form_id=form.id,
|
||||
message_id="msg-1",
|
||||
workflow_run_id=form.workflow_run_id,
|
||||
)
|
||||
content.form = form
|
||||
|
||||
recipient = HumanInputFormRecipient(
|
||||
form_id=form.id,
|
||||
delivery_id="delivery-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
recipient_payload=ConsoleRecipientPayload(account_id=None).model_dump_json(),
|
||||
access_token="token-1",
|
||||
)
|
||||
|
||||
repository = SQLAlchemyExecutionExtraContentRepository(
|
||||
session_maker=_FakeSessionMaker(session=_FakeSession(values=[[content], [recipient]]))
|
||||
)
|
||||
|
||||
result = repository.get_by_message_ids(["msg-1"])
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 1
|
||||
domain_content = result[0][0]
|
||||
assert domain_content.submitted is False
|
||||
assert domain_content.workflow_run_id == "workflow-run"
|
||||
assert domain_content.form_definition is not None
|
||||
assert domain_content.form_definition.expiration_time == int(form.expiration_time.timestamp())
|
||||
assert domain_content.form_definition is not None
|
||||
form_definition = domain_content.form_definition
|
||||
assert form_definition.form_id == "form-1"
|
||||
assert form_definition.node_id == "node-id"
|
||||
assert form_definition.node_title == "Approval"
|
||||
assert form_definition.form_content == "Rendered block"
|
||||
assert form_definition.display_in_ui is True
|
||||
assert form_definition.form_token == "token-1"
|
||||
assert form_definition.resolved_default_values == {"name": "John"}
|
||||
assert form_definition.expiration_time == int(form.expiration_time.timestamp())
|
||||
@@ -1,73 +0,0 @@
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import services.attachment_service as attachment_service_module
|
||||
from models.model import UploadFile
|
||||
from services.attachment_service import AttachmentService
|
||||
|
||||
|
||||
class TestAttachmentService:
|
||||
def test_should_initialize_with_sessionmaker_when_sessionmaker_is_provided(self):
|
||||
"""Test that AttachmentService keeps the provided sessionmaker instance."""
|
||||
session_factory = sessionmaker()
|
||||
|
||||
service = AttachmentService(session_factory=session_factory)
|
||||
|
||||
assert service._session_maker is session_factory
|
||||
|
||||
def test_should_initialize_with_bound_sessionmaker_when_engine_is_provided(self):
|
||||
"""Test that AttachmentService builds a sessionmaker bound to the provided engine."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
service = AttachmentService(session_factory=engine)
|
||||
session = service._session_maker()
|
||||
try:
|
||||
assert session.bind == engine
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
@pytest.mark.parametrize("invalid_session_factory", [None, "not-a-session-factory", 1])
|
||||
def test_should_raise_assertion_error_when_session_factory_type_is_invalid(self, invalid_session_factory):
|
||||
"""Test that invalid session_factory types are rejected."""
|
||||
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
||||
AttachmentService(session_factory=invalid_session_factory)
|
||||
|
||||
def test_should_return_base64_encoded_blob_when_file_exists(self):
|
||||
"""Test that existing files are loaded from storage and returned as base64."""
|
||||
service = AttachmentService(session_factory=sessionmaker())
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.key = "upload-file-key"
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value.where.return_value.first.return_value = upload_file
|
||||
service._session_maker = MagicMock(return_value=session)
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once", return_value=b"binary-content") as mock_load:
|
||||
result = service.get_file_base64("file-123")
|
||||
|
||||
assert result == base64.b64encode(b"binary-content").decode()
|
||||
service._session_maker.assert_called_once_with(expire_on_commit=False)
|
||||
session.query.assert_called_once_with(UploadFile)
|
||||
mock_load.assert_called_once_with("upload-file-key")
|
||||
|
||||
def test_should_raise_not_found_when_file_does_not_exist(self):
|
||||
"""Test that missing files raise NotFound and never call storage."""
|
||||
service = AttachmentService(session_factory=sessionmaker())
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value.where.return_value.first.return_value = None
|
||||
service._session_maker = MagicMock(return_value=session)
|
||||
|
||||
with patch.object(attachment_service_module.storage, "load_once") as mock_load:
|
||||
with pytest.raises(NotFound, match="File not found"):
|
||||
service.get_file_base64("missing-file")
|
||||
|
||||
service._session_maker.assert_called_once_with(expire_on_commit=False)
|
||||
session.query.assert_called_once_with(UploadFile)
|
||||
mock_load.assert_not_called()
|
||||
@@ -1,75 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_graph.variables import StringVariable
|
||||
from services.conversation_variable_updater import ConversationVariableNotFoundError, ConversationVariableUpdater
|
||||
|
||||
|
||||
class TestConversationVariableUpdater:
|
||||
def test_should_update_conversation_variable_data_and_commit(self):
|
||||
"""Test update persists serialized variable data when the row exists."""
|
||||
conversation_id = "conv-123"
|
||||
variable = StringVariable(
|
||||
id="var-123",
|
||||
name="topic",
|
||||
value="new value",
|
||||
)
|
||||
expected_json = variable.model_dump_json()
|
||||
|
||||
row = SimpleNamespace(data="old value")
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = row
|
||||
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
session_context.__exit__.return_value = None
|
||||
|
||||
session_maker = MagicMock(return_value=session_context)
|
||||
updater = ConversationVariableUpdater(session_maker)
|
||||
|
||||
updater.update(conversation_id=conversation_id, variable=variable)
|
||||
|
||||
session_maker.assert_called_once_with()
|
||||
session.scalar.assert_called_once()
|
||||
stmt = session.scalar.call_args.args[0]
|
||||
compiled_params = stmt.compile().params
|
||||
assert variable.id in compiled_params.values()
|
||||
assert conversation_id in compiled_params.values()
|
||||
assert row.data == expected_json
|
||||
session.commit.assert_called_once()
|
||||
|
||||
def test_should_raise_not_found_error_when_conversation_variable_missing(self):
|
||||
"""Test update raises ConversationVariableNotFoundError when no matching row exists."""
|
||||
conversation_id = "conv-404"
|
||||
variable = StringVariable(
|
||||
id="var-404",
|
||||
name="topic",
|
||||
value="value",
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
session_context.__exit__.return_value = None
|
||||
|
||||
session_maker = MagicMock(return_value=session_context)
|
||||
updater = ConversationVariableUpdater(session_maker)
|
||||
|
||||
with pytest.raises(ConversationVariableNotFoundError, match="conversation variable not found in the database"):
|
||||
updater.update(conversation_id=conversation_id, variable=variable)
|
||||
|
||||
session.commit.assert_not_called()
|
||||
|
||||
def test_should_do_nothing_when_flush_is_called(self):
|
||||
"""Test flush currently behaves as a no-op and returns None."""
|
||||
session_maker = MagicMock()
|
||||
updater = ConversationVariableUpdater(session_maker)
|
||||
|
||||
result = updater.flush()
|
||||
|
||||
assert result is None
|
||||
session_maker.assert_not_called()
|
||||
@@ -1,157 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import services.credit_pool_service as credit_pool_service_module
|
||||
from core.errors.error import QuotaExceededError
|
||||
from models import TenantCreditPool
|
||||
from services.credit_pool_service import CreditPoolService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credit_deduction_setup():
|
||||
"""Fixture providing common setup for credit deduction tests."""
|
||||
pool = SimpleNamespace(remaining_credits=50)
|
||||
fake_engine = MagicMock()
|
||||
session = MagicMock()
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
session_context.__exit__.return_value = None
|
||||
|
||||
mock_get_pool = patch.object(CreditPoolService, "get_pool", return_value=pool)
|
||||
mock_db = patch.object(credit_pool_service_module, "db", new=SimpleNamespace(engine=fake_engine))
|
||||
mock_session = patch.object(credit_pool_service_module, "Session", return_value=session_context)
|
||||
|
||||
return {
|
||||
"pool": pool,
|
||||
"fake_engine": fake_engine,
|
||||
"session": session,
|
||||
"session_context": session_context,
|
||||
"patches": (mock_get_pool, mock_db, mock_session),
|
||||
}
|
||||
|
||||
|
||||
class TestCreditPoolService:
|
||||
def test_should_create_default_pool_with_trial_type_and_configured_quota(self):
|
||||
"""Test create_default_pool persists a trial pool using configured hosted credits."""
|
||||
tenant_id = "tenant-123"
|
||||
hosted_pool_credits = 5000
|
||||
|
||||
with (
|
||||
patch.object(credit_pool_service_module.dify_config, "HOSTED_POOL_CREDITS", hosted_pool_credits),
|
||||
patch.object(credit_pool_service_module, "db") as mock_db,
|
||||
):
|
||||
pool = CreditPoolService.create_default_pool(tenant_id)
|
||||
|
||||
assert isinstance(pool, TenantCreditPool)
|
||||
assert pool.tenant_id == tenant_id
|
||||
assert pool.pool_type == "trial"
|
||||
assert pool.quota_limit == hosted_pool_credits
|
||||
assert pool.quota_used == 0
|
||||
mock_db.session.add.assert_called_once_with(pool)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
def test_should_return_first_pool_from_query_when_get_pool_called(self):
|
||||
"""Test get_pool queries by tenant and pool_type and returns first result."""
|
||||
tenant_id = "tenant-123"
|
||||
pool_type = "enterprise"
|
||||
expected_pool = MagicMock(spec=TenantCreditPool)
|
||||
|
||||
with patch.object(credit_pool_service_module, "db") as mock_db:
|
||||
query = mock_db.session.query.return_value
|
||||
filtered_query = query.filter_by.return_value
|
||||
filtered_query.first.return_value = expected_pool
|
||||
|
||||
result = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=pool_type)
|
||||
|
||||
assert result == expected_pool
|
||||
mock_db.session.query.assert_called_once_with(TenantCreditPool)
|
||||
query.filter_by.assert_called_once_with(tenant_id=tenant_id, pool_type=pool_type)
|
||||
filtered_query.first.assert_called_once()
|
||||
|
||||
def test_should_return_false_when_pool_not_found_in_check_credits_available(self):
|
||||
"""Test check_credits_available returns False when tenant has no pool."""
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=None) as mock_get_pool:
|
||||
result = CreditPoolService.check_credits_available(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
assert result is False
|
||||
mock_get_pool.assert_called_once_with("tenant-123", "trial")
|
||||
|
||||
def test_should_return_true_when_remaining_credits_cover_required_amount(self):
|
||||
"""Test check_credits_available returns True when remaining credits are sufficient."""
|
||||
pool = SimpleNamespace(remaining_credits=100)
|
||||
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=pool) as mock_get_pool:
|
||||
result = CreditPoolService.check_credits_available(tenant_id="tenant-123", credits_required=60)
|
||||
|
||||
assert result is True
|
||||
mock_get_pool.assert_called_once_with("tenant-123", "trial")
|
||||
|
||||
def test_should_return_false_when_remaining_credits_are_insufficient(self):
|
||||
"""Test check_credits_available returns False when required credits exceed remaining credits."""
|
||||
pool = SimpleNamespace(remaining_credits=30)
|
||||
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=pool):
|
||||
result = CreditPoolService.check_credits_available(tenant_id="tenant-123", credits_required=60)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_should_raise_quota_exceeded_when_pool_not_found_in_check_and_deduct(self):
|
||||
"""Test check_and_deduct_credits raises when tenant credit pool does not exist."""
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=None):
|
||||
with pytest.raises(QuotaExceededError, match="Credit pool not found"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
def test_should_raise_quota_exceeded_when_pool_has_no_remaining_credits(self):
|
||||
"""Test check_and_deduct_credits raises when remaining credits are zero or negative."""
|
||||
pool = SimpleNamespace(remaining_credits=0)
|
||||
|
||||
with patch.object(CreditPoolService, "get_pool", return_value=pool):
|
||||
with pytest.raises(QuotaExceededError, match="No credits remaining"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
def test_should_deduct_minimum_of_required_and_remaining_credits(self, mock_credit_deduction_setup):
|
||||
"""Test check_and_deduct_credits updates quota_used by the actual deducted amount."""
|
||||
tenant_id = "tenant-123"
|
||||
pool_type = "trial"
|
||||
credits_required = 200
|
||||
remaining_credits = 120
|
||||
expected_deducted_credits = 120
|
||||
|
||||
mock_credit_deduction_setup["pool"].remaining_credits = remaining_credits
|
||||
patches = mock_credit_deduction_setup["patches"]
|
||||
session = mock_credit_deduction_setup["session"]
|
||||
|
||||
with patches[0], patches[1], patches[2]:
|
||||
result = CreditPoolService.check_and_deduct_credits(
|
||||
tenant_id=tenant_id,
|
||||
credits_required=credits_required,
|
||||
pool_type=pool_type,
|
||||
)
|
||||
|
||||
assert result == expected_deducted_credits
|
||||
session.execute.assert_called_once()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
stmt = session.execute.call_args.args[0]
|
||||
compiled_params = stmt.compile().params
|
||||
assert tenant_id in compiled_params.values()
|
||||
assert pool_type in compiled_params.values()
|
||||
assert expected_deducted_credits in compiled_params.values()
|
||||
|
||||
def test_should_raise_quota_exceeded_when_deduction_update_fails(self, mock_credit_deduction_setup):
|
||||
"""Test check_and_deduct_credits translates DB update failures to QuotaExceededError."""
|
||||
mock_credit_deduction_setup["pool"].remaining_credits = 50
|
||||
mock_credit_deduction_setup["session"].execute.side_effect = Exception("db failure")
|
||||
session = mock_credit_deduction_setup["session"]
|
||||
|
||||
patches = mock_credit_deduction_setup["patches"]
|
||||
mock_logger = patch.object(credit_pool_service_module, "logger")
|
||||
|
||||
with patches[0], patches[1], patches[2], mock_logger as mock_logger_obj:
|
||||
with pytest.raises(QuotaExceededError, match="Failed to deduct credits"):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-123", credits_required=10)
|
||||
|
||||
session.commit.assert_not_called()
|
||||
mock_logger_obj.exception.assert_called_once()
|
||||
@@ -1,305 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.account import Account, TenantAccountRole
|
||||
from models.dataset import Dataset, DatasetPermission, DatasetPermissionEnum
|
||||
from services.dataset_service import DatasetService
|
||||
from services.errors.account import NoPermissionError
|
||||
|
||||
|
||||
class DatasetPermissionTestDataFactory:
|
||||
"""Factory class for creating test data and mock objects for dataset permission tests."""
|
||||
|
||||
@staticmethod
|
||||
def create_dataset_mock(
|
||||
dataset_id: str = "dataset-123",
|
||||
tenant_id: str = "test-tenant-123",
|
||||
created_by: str = "creator-456",
|
||||
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock dataset with specified attributes."""
|
||||
dataset = Mock(spec=Dataset)
|
||||
dataset.id = dataset_id
|
||||
dataset.tenant_id = tenant_id
|
||||
dataset.created_by = created_by
|
||||
dataset.permission = permission
|
||||
for key, value in kwargs.items():
|
||||
setattr(dataset, key, value)
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def create_user_mock(
|
||||
user_id: str = "user-789",
|
||||
tenant_id: str = "test-tenant-123",
|
||||
role: TenantAccountRole = TenantAccountRole.NORMAL,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock user with specified attributes."""
|
||||
user = Mock(spec=Account)
|
||||
user.id = user_id
|
||||
user.current_tenant_id = tenant_id
|
||||
user.current_role = role
|
||||
for key, value in kwargs.items():
|
||||
setattr(user, key, value)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_dataset_permission_mock(
|
||||
dataset_id: str = "dataset-123",
|
||||
account_id: str = "user-789",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock dataset permission record."""
|
||||
permission = Mock(spec=DatasetPermission)
|
||||
permission.dataset_id = dataset_id
|
||||
permission.account_id = account_id
|
||||
for key, value in kwargs.items():
|
||||
setattr(permission, key, value)
|
||||
return permission
|
||||
|
||||
|
||||
class TestDatasetPermissionService:
|
||||
"""
|
||||
Comprehensive unit tests for DatasetService.check_dataset_permission method.
|
||||
|
||||
This test suite covers all permission scenarios including:
|
||||
- Cross-tenant access restrictions
|
||||
- Owner privilege checks
|
||||
- Different permission levels (ONLY_ME, ALL_TEAM, PARTIAL_TEAM)
|
||||
- Explicit permission checks for PARTIAL_TEAM
|
||||
- Error conditions and logging
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dataset_service_dependencies(self):
|
||||
"""Common mock setup for dataset service dependencies."""
|
||||
with patch("services.dataset_service.db.session") as mock_session:
|
||||
yield {
|
||||
"db_session": mock_session,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logging_dependencies(self):
|
||||
"""Mock setup for logging tests."""
|
||||
with patch("services.dataset_service.logger") as mock_logging:
|
||||
yield {
|
||||
"logging": mock_logging,
|
||||
}
|
||||
|
||||
def _assert_permission_check_passes(self, dataset: Mock, user: Mock):
|
||||
"""Helper method to verify that permission check passes without raising exceptions."""
|
||||
# Should not raise any exception
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
def _assert_permission_check_fails(
|
||||
self, dataset: Mock, user: Mock, expected_message: str = "You do not have permission to access this dataset."
|
||||
):
|
||||
"""Helper method to verify that permission check fails with expected error."""
|
||||
with pytest.raises(NoPermissionError, match=expected_message):
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
def _assert_database_query_called(self, mock_session: Mock, dataset_id: str, account_id: str):
|
||||
"""Helper method to verify database query calls for permission checks."""
|
||||
mock_session.query().filter_by.assert_called_with(dataset_id=dataset_id, account_id=account_id)
|
||||
|
||||
def _assert_database_query_not_called(self, mock_session: Mock):
|
||||
"""Helper method to verify that database query was not called."""
|
||||
mock_session.query.assert_not_called()
|
||||
|
||||
# ==================== Cross-Tenant Access Tests ====================
|
||||
|
||||
def test_permission_check_different_tenant_should_fail(self):
|
||||
"""Test that users from different tenants cannot access dataset regardless of other permissions."""
|
||||
# Create dataset and user from different tenants
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
tenant_id="tenant-123", permission=DatasetPermissionEnum.ALL_TEAM
|
||||
)
|
||||
user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="user-789", tenant_id="different-tenant-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Should fail due to different tenant
|
||||
self._assert_permission_check_fails(dataset, user)
|
||||
|
||||
# ==================== Owner Privilege Tests ====================
|
||||
|
||||
def test_owner_can_access_any_dataset(self):
|
||||
"""Test that tenant owners can access any dataset regardless of permission level."""
|
||||
# Create dataset with restrictive permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ONLY_ME)
|
||||
|
||||
# Create owner user
|
||||
owner_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="owner-999", role=TenantAccountRole.OWNER
|
||||
)
|
||||
|
||||
# Owner should have access regardless of dataset permission
|
||||
self._assert_permission_check_passes(dataset, owner_user)
|
||||
|
||||
# ==================== ONLY_ME Permission Tests ====================
|
||||
|
||||
def test_only_me_permission_creator_can_access(self):
|
||||
"""Test ONLY_ME permission allows only the dataset creator to access."""
|
||||
# Create dataset with ONLY_ME permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
# Create creator user
|
||||
creator_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="creator-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Creator should be able to access
|
||||
self._assert_permission_check_passes(dataset, creator_user)
|
||||
|
||||
def test_only_me_permission_others_cannot_access(self):
|
||||
"""Test ONLY_ME permission denies access to non-creators."""
|
||||
# Create dataset with ONLY_ME permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.ONLY_ME
|
||||
)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Non-creator should be denied access
|
||||
self._assert_permission_check_fails(dataset, normal_user)
|
||||
|
||||
# ==================== ALL_TEAM Permission Tests ====================
|
||||
|
||||
def test_all_team_permission_allows_access(self):
|
||||
"""Test ALL_TEAM permission allows any team member to access the dataset."""
|
||||
# Create dataset with ALL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ALL_TEAM)
|
||||
|
||||
# Create different types of team members
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
editor_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="editor-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# All team members should have access
|
||||
self._assert_permission_check_passes(dataset, normal_user)
|
||||
self._assert_permission_check_passes(dataset, editor_user)
|
||||
|
||||
# ==================== PARTIAL_TEAM Permission Tests ====================
|
||||
|
||||
def test_partial_team_permission_creator_can_access(self, mock_dataset_service_dependencies):
|
||||
"""Test PARTIAL_TEAM permission allows creator to access without database query."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
# Create creator user
|
||||
creator_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="creator-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Creator should have access without database query
|
||||
self._assert_permission_check_passes(dataset, creator_user)
|
||||
self._assert_database_query_not_called(mock_dataset_service_dependencies["db_session"])
|
||||
|
||||
def test_partial_team_permission_with_explicit_permission(self, mock_dataset_service_dependencies):
|
||||
"""Test PARTIAL_TEAM permission allows users with explicit permission records."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return a permission record
|
||||
mock_permission = DatasetPermissionTestDataFactory.create_dataset_permission_mock(
|
||||
dataset_id=dataset.id, account_id=normal_user.id
|
||||
)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = mock_permission
|
||||
|
||||
# User with explicit permission should have access
|
||||
self._assert_permission_check_passes(dataset, normal_user)
|
||||
self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, normal_user.id)
|
||||
|
||||
def test_partial_team_permission_without_explicit_permission(self, mock_dataset_service_dependencies):
|
||||
"""Test PARTIAL_TEAM permission denies users without explicit permission records."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return None (no permission record)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
|
||||
|
||||
# User without explicit permission should be denied access
|
||||
self._assert_permission_check_fails(dataset, normal_user)
|
||||
self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, normal_user.id)
|
||||
|
||||
def test_partial_team_permission_non_creator_without_permission_fails(self, mock_dataset_service_dependencies):
|
||||
"""Test that non-creators without explicit permission are denied access to PARTIAL_TEAM datasets."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
# Create a different user (not the creator)
|
||||
other_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="other-user-123", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return None (no permission record)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
|
||||
|
||||
# Non-creator without explicit permission should be denied access
|
||||
self._assert_permission_check_fails(dataset, other_user)
|
||||
self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, other_user.id)
|
||||
|
||||
# ==================== Enum Usage Tests ====================
|
||||
|
||||
def test_partial_team_permission_uses_correct_enum(self):
|
||||
"""Test that the method correctly uses DatasetPermissionEnum.PARTIAL_TEAM instead of string literals."""
|
||||
# Create dataset with PARTIAL_TEAM permission using enum
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
|
||||
created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
|
||||
)
|
||||
|
||||
# Create creator user
|
||||
creator_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="creator-456", role=TenantAccountRole.EDITOR
|
||||
)
|
||||
|
||||
# Creator should always have access regardless of permission level
|
||||
self._assert_permission_check_passes(dataset, creator_user)
|
||||
|
||||
# ==================== Logging Tests ====================
|
||||
|
||||
def test_permission_denied_logs_debug_message(self, mock_dataset_service_dependencies, mock_logging_dependencies):
|
||||
"""Test that permission denied events are properly logged for debugging purposes."""
|
||||
# Create dataset with PARTIAL_TEAM permission
|
||||
dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
|
||||
|
||||
# Create normal user (not the creator)
|
||||
normal_user = DatasetPermissionTestDataFactory.create_user_mock(
|
||||
user_id="normal-789", role=TenantAccountRole.NORMAL
|
||||
)
|
||||
|
||||
# Mock database query to return None (no permission record)
|
||||
mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
|
||||
|
||||
# Attempt permission check (should fail)
|
||||
with pytest.raises(NoPermissionError):
|
||||
DatasetService.check_dataset_permission(dataset, normal_user)
|
||||
|
||||
# Verify debug message was logged with correct user and dataset information
|
||||
mock_logging_dependencies["logging"].debug.assert_called_with(
|
||||
"User %s does not have permission to access dataset %s", normal_user.id, dataset.id
|
||||
)
|
||||
@@ -75,6 +75,7 @@ import pytest
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from services.tag_service import TagService
|
||||
|
||||
@@ -102,7 +103,7 @@ class TagServiceTestDataFactory:
|
||||
def create_tag_mock(
|
||||
tag_id: str = "tag-123",
|
||||
name: str = "Test Tag",
|
||||
tag_type: str = "app",
|
||||
tag_type: TagType = TagType.APP,
|
||||
tenant_id: str = "tenant-123",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
@@ -705,7 +706,7 @@ class TestTagServiceCRUD:
|
||||
# Verify tag attributes
|
||||
added_tag = mock_db_session.add.call_args[0][0]
|
||||
assert added_tag.name == "New Tag", "Tag name should match"
|
||||
assert added_tag.type == "app", "Tag type should match"
|
||||
assert added_tag.type == TagType.APP, "Tag type should match"
|
||||
assert added_tag.created_by == "user-123", "Created by should match current user"
|
||||
assert added_tag.tenant_id == "tenant-123", "Tenant ID should match current tenant"
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_setup():
|
||||
mock_session_maker = MagicMock()
|
||||
workflow_service = WorkflowService(mock_session_maker)
|
||||
session = MagicMock(spec=Session)
|
||||
tenant_id = "test-tenant-id"
|
||||
workflow_id = "test-workflow-id"
|
||||
|
||||
# Mock workflow
|
||||
workflow = MagicMock(spec=Workflow)
|
||||
workflow.id = workflow_id
|
||||
workflow.tenant_id = tenant_id
|
||||
workflow.version = "1.0" # Not a draft
|
||||
workflow.tool_published = False # Not published as a tool by default
|
||||
|
||||
# Mock app
|
||||
app = MagicMock(spec=App)
|
||||
app.id = "test-app-id"
|
||||
app.name = "Test App"
|
||||
app.workflow_id = None # Not used by an app by default
|
||||
|
||||
return {
|
||||
"workflow_service": workflow_service,
|
||||
"session": session,
|
||||
"tenant_id": tenant_id,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow": workflow,
|
||||
"app": app,
|
||||
}
|
||||
|
||||
|
||||
def test_delete_workflow_success(workflow_setup):
|
||||
# Setup mocks
|
||||
|
||||
# Mock the tool provider query to return None (not published as a tool)
|
||||
workflow_setup["session"].query.return_value.where.return_value.first.return_value = None
|
||||
|
||||
workflow_setup["session"].scalar = MagicMock(
|
||||
side_effect=[workflow_setup["workflow"], None]
|
||||
) # Return workflow first, then None for app
|
||||
|
||||
# Call the method
|
||||
result = workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
workflow_setup["session"].delete.assert_called_once_with(workflow_setup["workflow"])
|
||||
|
||||
|
||||
def test_delete_workflow_draft_error(workflow_setup):
|
||||
# Setup mocks
|
||||
workflow_setup["workflow"].version = "draft"
|
||||
workflow_setup["session"].scalar = MagicMock(return_value=workflow_setup["workflow"])
|
||||
|
||||
# Call the method and verify exception
|
||||
with pytest.raises(DraftWorkflowDeletionError):
|
||||
workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify
|
||||
workflow_setup["session"].delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_workflow_in_use_by_app_error(workflow_setup):
|
||||
# Setup mocks
|
||||
workflow_setup["app"].workflow_id = workflow_setup["workflow_id"]
|
||||
workflow_setup["session"].scalar = MagicMock(
|
||||
side_effect=[workflow_setup["workflow"], workflow_setup["app"]]
|
||||
) # Return workflow first, then app
|
||||
|
||||
# Call the method and verify exception
|
||||
with pytest.raises(WorkflowInUseError) as excinfo:
|
||||
workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify error message contains app name
|
||||
assert "Cannot delete workflow that is currently in use by app" in str(excinfo.value)
|
||||
|
||||
# Verify
|
||||
workflow_setup["session"].delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_workflow_published_as_tool_error(workflow_setup):
|
||||
# Setup mocks
|
||||
from models.tools import WorkflowToolProvider
|
||||
|
||||
# Mock the tool provider query
|
||||
mock_tool_provider = MagicMock(spec=WorkflowToolProvider)
|
||||
workflow_setup["session"].query.return_value.where.return_value.first.return_value = mock_tool_provider
|
||||
|
||||
workflow_setup["session"].scalar = MagicMock(
|
||||
side_effect=[workflow_setup["workflow"], None]
|
||||
) # Return workflow first, then None for app
|
||||
|
||||
# Call the method and verify exception
|
||||
with pytest.raises(WorkflowInUseError) as excinfo:
|
||||
workflow_setup["workflow_service"].delete_workflow(
|
||||
session=workflow_setup["session"],
|
||||
workflow_id=workflow_setup["workflow_id"],
|
||||
tenant_id=workflow_setup["tenant_id"],
|
||||
)
|
||||
|
||||
# Verify error message
|
||||
assert "Cannot delete workflow that is published as a tool" in str(excinfo.value)
|
||||
|
||||
# Verify
|
||||
workflow_setup["session"].delete.assert_not_called()
|
||||
360
api/uv.lock
generated
360
api/uv.lock
generated
@@ -169,12 +169,6 @@ version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-endpoint-util"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-gateway-spi"
|
||||
version = "0.0.3"
|
||||
@@ -186,69 +180,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf7
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-gpdb20160503"
|
||||
version = "3.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-endpoint-util" },
|
||||
{ name = "alibabacloud-openapi-util" },
|
||||
{ name = "alibabacloud-openplatform20191219" },
|
||||
{ name = "alibabacloud-oss-sdk" },
|
||||
{ name = "alibabacloud-oss-util" },
|
||||
{ name = "alibabacloud-tea-fileform" },
|
||||
{ name = "alibabacloud-tea-openapi" },
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/6a/cc72e744e95c8f37fa6a84e66ae0b9b57a13ee97a0ef03d94c7127c31d75/alibabacloud_gpdb20160503-3.8.3.tar.gz", hash = "sha256:4dfcc0d9cff5a921d529d76f4bf97e2ceb9dc2fa53f00ab055f08509423d8e30", size = 155092, upload-time = "2024-07-18T17:09:42.438Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/36/bce41704b3bf59d607590ec73a42a254c5dea27c0f707aee11d20512a200/alibabacloud_gpdb20160503-3.8.3-py3-none-any.whl", hash = "sha256:06e1c46ce5e4e9d1bcae76e76e51034196c625799d06b2efec8d46a7df323fe8", size = 156097, upload-time = "2024-07-18T17:09:40.414Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-openapi-util"
|
||||
version = "0.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/50/5f41ab550d7874c623f6e992758429802c4b52a6804db437017e5387de33/alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8", size = 7201, upload-time = "2023-10-23T07:44:18.523Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-openplatform20191219"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-endpoint-util" },
|
||||
{ name = "alibabacloud-openapi-util" },
|
||||
{ name = "alibabacloud-tea-openapi" },
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/bf/f7fa2f3657ed352870f442434cb2f27b7f70dcd52a544a1f3998eeaf6d71/alibabacloud_openplatform20191219-2.0.0.tar.gz", hash = "sha256:e67f4c337b7542538746592c6a474bd4ae3a9edccdf62e11a32ca61fad3c9020", size = 5038, upload-time = "2022-09-21T06:16:10.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e5/18c75213551eeca9db1f6b41ddcc0bd87b5b6508c75a67f05cd8671847b4/alibabacloud_openplatform20191219-2.0.0-py3-none-any.whl", hash = "sha256:873821c45bca72a6c6ec7a906c9cb21554c122e88893bbac3986934dab30dd36", size = 5204, upload-time = "2022-09-21T06:16:07.844Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-oss-sdk"
|
||||
version = "0.1.1"
|
||||
version = "5.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
{ name = "alibabacloud-oss-util" },
|
||||
{ name = "alibabacloud-tea-fileform" },
|
||||
{ name = "alibabacloud-tea-util" },
|
||||
{ name = "alibabacloud-tea-xml" },
|
||||
{ name = "alibabacloud-tea-openapi" },
|
||||
{ name = "darabonba-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/d1/f442dd026908fcf55340ca694bb1d027aa91e119e76ae2fbea62f2bde4f4/alibabacloud_oss_sdk-0.1.1.tar.gz", hash = "sha256:f51a368020d0964fcc0978f96736006f49f5ab6a4a4bf4f0b8549e2c659e7358", size = 46434, upload-time = "2025-04-22T12:40:41.717Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-oss-util"
|
||||
version = "0.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea" },
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/36/69333c7fb7fb5267f338371b14fdd8dbdd503717c97bbc7a6419d155ab4c/alibabacloud_gpdb20160503-5.1.0.tar.gz", hash = "sha256:086ec6d5e39b64f54d0e44bb3fd4fde1a4822a53eb9f6ff7464dff7d19b07b63", size = 295641, upload-time = "2026-03-19T10:09:02.444Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7f/a91a2f9ad97c92fa9a6981587ea0ff789240cea05b17b17b7c244e5bac64/alibabacloud_gpdb20160503-5.1.0-py3-none-any.whl", hash = "sha256:580e4579285a54c7f04570782e0f60423a1997568684187fe88e4110acfb640e", size = 848784, upload-time = "2026-03-19T10:09:00.72Z" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/7c/d7e812b9968247a302573daebcfef95d0f9a718f7b4bfcca8d3d83e266be/alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6", size = 10008, upload-time = "2021-04-28T09:25:04.056Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea"
|
||||
@@ -260,15 +202,6 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-fileform"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/8a/ef8ddf5ee0350984cad2749414b420369fe943e15e6d96b79be45367630e/alibabacloud_tea_fileform-0.0.5.tar.gz", hash = "sha256:fd00a8c9d85e785a7655059e9651f9e91784678881831f60589172387b968ee8", size = 3961, upload-time = "2021-04-28T09:22:54.56Z" }
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.3"
|
||||
@@ -297,15 +230,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-xml"
|
||||
version = "0.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-tea" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" }
|
||||
|
||||
[[package]]
|
||||
name = "aliyun-log-python-sdk"
|
||||
version = "0.9.37"
|
||||
@@ -570,28 +494,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.38.2"
|
||||
version = "1.38.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodejs-wheel-binaries" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/a3/20aa7c4e83f2f614e0036300f3c352775dede0655c66814da16c37b661a9/basedpyright-1.38.2.tar.gz", hash = "sha256:b433b2b8ba745ed7520cdc79a29a03682f3fb00346d272ece5944e9e5e5daa92", size = 25277019, upload-time = "2026-02-26T11:18:43.594Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/58/7abba2c743571a42b2548f07aee556ebc1e4d0bc2b277aeba1ee6c83b0af/basedpyright-1.38.3.tar.gz", hash = "sha256:9725419786afbfad8a9539527f162da02d462afad440b0412fdb3f3cdf179b90", size = 25277430, upload-time = "2026-03-17T13:10:41.526Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/12/736cab83626fea3fe65cdafb3ef3d2ee9480c56723f2fd33921537289a5e/basedpyright-1.38.2-py3-none-any.whl", hash = "sha256:153481d37fd19f9e3adedc8629d1d071b10c5f5e49321fb026b74444b7c70e24", size = 12312475, upload-time = "2026-02-26T11:18:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e3/3ebb5c23bd3abb5fc2053b8a06a889aa5c1cf8cff738c78cb6c1957e90cd/basedpyright-1.38.3-py3-none-any.whl", hash = "sha256:1f15c2e489c67d6c5e896c24b6a63251195c04223a55e4568b8f8e8ed49ca830", size = 12313363, upload-time = "2026-03-17T13:10:47.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bce-python-sdk"
|
||||
version = "0.9.63"
|
||||
version = "0.9.64"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "future" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ab/4c2927b01a97562af6a296b722eee79658335795f341a395a12742d5e1a3/bce_python_sdk-0.9.63.tar.gz", hash = "sha256:0c80bc3ac128a0a144bae3b8dff1f397f42c30b36f7677e3a39d8df8e77b1088", size = 284419, upload-time = "2026-03-06T14:54:06.592Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/33/047e9c1a6c97e0cd4d93a6490abd8fbc2ccd13569462fc0228699edc08bc/bce_python_sdk-0.9.64.tar.gz", hash = "sha256:901bf787c26ad35855a80d65e58d7584c8541f7f0f2af20847830e572e5b622e", size = 287125, upload-time = "2026-03-17T11:24:29.345Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a4/501e978776c7060aa8ba77e68536597e754d938bcdbe1826618acebfbddf/bce_python_sdk-0.9.63-py3-none-any.whl", hash = "sha256:ec66eee8807c6aa4036412592da7e8c9e2cd7fdec494190986288ac2195d8276", size = 400305, upload-time = "2026-03-06T14:53:52.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7f/dd289582f37ab4effea47b2a8503880db4781ca0fc8e0a8ed5ff493359e5/bce_python_sdk-0.9.64-py3-none-any.whl", hash = "sha256:eaad97e4f0e7d613ae978da3cdc5294e9f724ffca2735f79820037fa1317cd6d", size = 402233, upload-time = "2026-03-17T11:24:24.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -660,14 +584,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bleach"
|
||||
version = "6.2.0"
|
||||
version = "6.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "webencodings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -706,30 +630,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.68"
|
||||
version = "1.42.73"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.42.68"
|
||||
version = "1.42.73"
|
||||
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/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/c3/fcc47102c63278af25ad57c93d97dc393f4dbc54c0117a29c78f2b96ec1e/boto3_stubs-1.42.73.tar.gz", hash = "sha256:36f625769b5505c4bc627f16244b98de9e10dae3ac36f1aa0f0ebe2f201dc138", size = 101373, upload-time = "2026-03-20T19:59:51.463Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/57/d570ba61a2a0c7fe0c8667e41269a0480293cb53e1786d6661a2bd827fc5/boto3_stubs-1.42.73-py3-none-any.whl", hash = "sha256:bd658429069d8215247fc3abc003220cd875c24ab6eda7b3405090408afaacdf", size = 70009, upload-time = "2026-03-20T19:59:43.786Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -739,16 +663,16 @@ bedrock-runtime = [
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.68"
|
||||
version = "1.42.73"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/22/87502d5fbbfa8189406a617b30b1e2a3dc0ab2669f7268e91b385c1c1c7a/botocore-1.42.68.tar.gz", hash = "sha256:3951c69e12ac871dda245f48dac5c7dd88ea1bfdd74a8879ec356cf2874b806a", size = 14994514, upload-time = "2026-03-13T19:32:03.577Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2a/1428f6594799780fe6ee845d8e6aeffafe026cd16a70c878684e2dcbbfc8/botocore-1.42.68-py3-none-any.whl", hash = "sha256:9df7da26374601f890e2f115bfa573d65bf15b25fe136bb3aac809f6145f52ab", size = 14668816, upload-time = "2026-03-13T19:31:58.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1290,41 +1214,41 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
version = "7.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1744,8 +1668,8 @@ requires-dist = [
|
||||
{ name = "arize-phoenix-otel", specifier = "~=0.15.0" },
|
||||
{ name = "azure-identity", specifier = "==1.25.3" },
|
||||
{ name = "beautifulsoup4", specifier = "==4.14.3" },
|
||||
{ name = "bleach", specifier = "~=6.2.0" },
|
||||
{ name = "boto3", specifier = "==1.42.68" },
|
||||
{ name = "bleach", specifier = "~=6.3.0" },
|
||||
{ name = "boto3", specifier = "==1.42.73" },
|
||||
{ name = "bs4", specifier = "~=0.0.1" },
|
||||
{ name = "cachetools", specifier = "~=5.3.0" },
|
||||
{ name = "celery", specifier = "~=5.6.2" },
|
||||
@@ -1763,7 +1687,7 @@ requires-dist = [
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.3.0" },
|
||||
{ name = "google-api-core", specifier = ">=2.19.1" },
|
||||
{ name = "google-api-python-client", specifier = "==2.192.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.193.0" },
|
||||
{ name = "google-auth", specifier = ">=2.47.0" },
|
||||
{ name = "google-auth-httplib2", specifier = "==0.3.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.123.0" },
|
||||
@@ -1776,7 +1700,7 @@ requires-dist = [
|
||||
{ name = "jsonschema", specifier = ">=4.25.1" },
|
||||
{ name = "langfuse", specifier = "~=2.51.3" },
|
||||
{ name = "langsmith", specifier = "~=0.7.16" },
|
||||
{ name = "litellm", specifier = "==1.82.2" },
|
||||
{ name = "litellm", specifier = "==1.82.6" },
|
||||
{ name = "markdown", specifier = "~=3.10.2" },
|
||||
{ name = "mlflow-skinny", specifier = ">=3.0.0" },
|
||||
{ name = "numpy", specifier = "~=1.26.4" },
|
||||
@@ -1815,12 +1739,12 @@ requires-dist = [
|
||||
{ name = "pyyaml", specifier = "~=6.0.1" },
|
||||
{ name = "readabilipy", specifier = "~=0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=7.3.0" },
|
||||
{ name = "resend", specifier = "~=2.23.0" },
|
||||
{ name = "resend", specifier = "~=2.26.0" },
|
||||
{ name = "sendgrid", specifier = "~=6.12.3" },
|
||||
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.54.0" },
|
||||
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.55.0" },
|
||||
{ name = "sqlalchemy", specifier = "~=2.0.29" },
|
||||
{ name = "sseclient-py", specifier = "~=1.9.0" },
|
||||
{ name = "starlette", specifier = "==0.52.1" },
|
||||
{ name = "starlette", specifier = "==1.0.0" },
|
||||
{ name = "tiktoken", specifier = "~=0.12.0" },
|
||||
{ name = "transformers", specifier = "~=5.3.0" },
|
||||
{ name = "unstructured", extras = ["docx", "epub", "md", "ppt", "pptx"], specifier = "~=0.21.5" },
|
||||
@@ -1846,7 +1770,7 @@ dev = [
|
||||
{ name = "pyrefly", specifier = ">=0.55.0" },
|
||||
{ name = "pytest", specifier = "~=9.0.2" },
|
||||
{ name = "pytest-benchmark", specifier = "~=5.2.3" },
|
||||
{ name = "pytest-cov", specifier = "~=7.0.0" },
|
||||
{ name = "pytest-cov", specifier = "~=7.1.0" },
|
||||
{ name = "pytest-env", specifier = "~=1.6.0" },
|
||||
{ name = "pytest-mock", specifier = "~=3.15.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
@@ -1912,7 +1836,7 @@ tools = [
|
||||
{ name = "nltk", specifier = "~=3.9.1" },
|
||||
]
|
||||
vdb = [
|
||||
{ name = "alibabacloud-gpdb20160503", specifier = "~=3.8.0" },
|
||||
{ name = "alibabacloud-gpdb20160503", specifier = "~=5.1.0" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" },
|
||||
{ name = "chromadb", specifier = "==0.5.20" },
|
||||
{ name = "clickhouse-connect", specifier = "~=0.14.1" },
|
||||
@@ -2501,7 +2425,7 @@ grpc = [
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.192.0"
|
||||
version = "2.193.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2510,9 +2434,9 @@ dependencies = [
|
||||
{ name = "httplib2" },
|
||||
{ name = "uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/d8/489052a40935e45b9b5b3d6accc14b041360c1507bdc659c2e1a19aaa3ff/google_api_python_client-2.192.0.tar.gz", hash = "sha256:d48cfa6078fadea788425481b007af33fe0ab6537b78f37da914fb6fc112eb27", size = 14209505, upload-time = "2026-03-05T15:17:01.598Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006, upload-time = "2026-03-17T18:25:29.415Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/76/ec4128f00fefb9011635ae2abc67d7dacd05c8559378f8f05f0c907c38d8/google_api_python_client-2.192.0-py3-none-any.whl", hash = "sha256:63a57d4457cd97df1d63eb89c5fda03c5a50588dcbc32c0115dd1433c08f4b62", size = 14783267, upload-time = "2026-03-05T15:16:58.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490, upload-time = "2026-03-17T18:25:26.608Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2548,7 +2472,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
@@ -2564,9 +2488,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/dc/1209c7aab43bd7233cf631165a3b1b4284d22fc7fe7387c66228d07868ab/google_cloud_aiplatform-1.141.0.tar.gz", hash = "sha256:e3b1cdb28865dd862aac9c685dfc5ac076488705aba0a5354016efadcddd59c6", size = 10152688, upload-time = "2026-03-10T22:20:08.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/0d/3063a0512d60cf18854a279e00ccb796429545464345ef821cf77cb93d05/google_cloud_aiplatform-1.142.0.tar.gz", hash = "sha256:87b49e002703dc14885093e9b264587db84222bef5f70f5a442d03f41beecdd1", size = 10207993, upload-time = "2026-03-20T22:49:13.797Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/fc/428af69a69ff2e477e7f5e12d227b31fe5790f1a8234aacd54297f49c836/google_cloud_aiplatform-1.141.0-py2.py3-none-any.whl", hash = "sha256:6bd25b4d514c40b8181ca703e1b313ad6d0454ab8006fc9907fb3e9f672f31d1", size = 8358409, upload-time = "2026-03-10T22:20:04.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8b/f29646d3fa940f0e38cfcc12137f4851856b50d7486a3c05103ebc78d82d/google_cloud_aiplatform-1.142.0-py2.py3-none-any.whl", hash = "sha256:17c91db9b613cbbafb2c36335b123686aeb2b4b8448be5134b565ae07165a39a", size = 8388991, upload-time = "2026-03-20T22:49:10.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2619,7 +2543,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "3.9.0"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2629,9 +2553,9 @@ dependencies = [
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/e3/747759eebc72e420c25903d6bc231d0ceb110b66ac7e6ee3f350417152cd/google_cloud_storage-3.10.0.tar.gz", hash = "sha256:1aeebf097c27d718d84077059a28d7e87f136f3700212215f1ceeae1d1c5d504", size = 17309829, upload-time = "2026-03-18T15:54:11.875Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/e2/d58442f4daee5babd9255cf492a1f3d114357164072f8339a22a3ad460a2/google_cloud_storage-3.10.0-py3-none-any.whl", hash = "sha256:0072e7783b201e45af78fd9779894cdb6bec2bf922ee932f3fcc16f8bce9b9a3", size = 324382, upload-time = "2026-03-18T15:54:10.091Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3460,7 +3384,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.17"
|
||||
version = "0.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -3473,9 +3397,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/79/81041dde07a974e728db7def23c1c7255950b8874102925cc77093bc847d/langsmith-0.7.17.tar.gz", hash = "sha256:6c1b0c2863cdd6636d2a58b8d5b1b80060703d98cac2593f4233e09ac25b5a9d", size = 1132228, upload-time = "2026-03-12T20:41:10.808Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/2a/2d5e6c67396fd228670af278c4da7bd6db2b8d11deaf6f108490b6d3f561/langsmith-0.7.22.tar.gz", hash = "sha256:35bfe795d648b069958280760564632fd28ebc9921c04f3e209c0db6a6c7dc04", size = 1134923, upload-time = "2026-03-19T22:45:23.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/31/62689d57f4d25792bd6a3c05c868771899481be2f3e31f9e71d31e1ac4ab/langsmith-0.7.17-py3-none-any.whl", hash = "sha256:cbec10460cb6c6ecc94c18c807be88a9984838144ae6c4693c9f859f378d7d02", size = 359147, upload-time = "2026-03-12T20:41:08.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/94/1f5d72655ab6534129540843776c40eff757387b88e798d8b3bf7e313fd4/langsmith-0.7.22-py3-none-any.whl", hash = "sha256:6e9d5148314d74e86748cb9d3898632cad0320c9323d95f70f969e5bc078eee4", size = 359927, upload-time = "2026-03-19T22:45:21.603Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3523,7 +3447,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.82.2"
|
||||
version = "1.82.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3539,9 +3463,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/12/010a86643f12ac0b004032d5927c260094299a84ed38b5ed20a8f8c7e3c4/litellm-1.82.2.tar.gz", hash = "sha256:f5f4c4049f344a88bf80b2e421bb927807687c99624515d7ff4152d533ec9dcb", size = 17353218, upload-time = "2026-03-13T21:24:24.5Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e4/87e3ca82a8bf6e6bfffb42a539a1350dd6ced1b7169397bd439ba56fde10/litellm-1.82.2-py3-none-any.whl", hash = "sha256:641ed024774fa3d5b4dd9347f0efb1e31fa422fba2a6500aabedee085d1194cb", size = 15524224, upload-time = "2026-03-13T21:24:21.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4538,7 +4462,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "opik"
|
||||
version = "1.10.39"
|
||||
version = "1.10.45"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "boto3-stubs", extra = ["bedrock-runtime"] },
|
||||
@@ -4557,9 +4481,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "uuid6" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/0f/b1e00a18cac16b4f36bf6cecc2de962fda810a9416d1159c48f46b81f5ec/opik-1.10.39.tar.gz", hash = "sha256:4d808eb2137070fc5d92a3bed3c3100d9cccfb35f4f0b71ea9990733f293dbb2", size = 780312, upload-time = "2026-03-12T14:08:25.746Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/17/edea6308347cec62e6828de7c573c596559c502b54fa4f0c88a52e2e81f5/opik-1.10.45.tar.gz", hash = "sha256:d8d8627ba03d12def46965e03d58f611daaf5cf878b3d087c53fe1159788c140", size = 789876, upload-time = "2026-03-20T11:35:12.457Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/24/0f4404907a98b4aec4508504570a78a61a3a8b5e451c67326632695ba8e6/opik-1.10.39-py3-none-any.whl", hash = "sha256:a72d735b9afac62e5262294b2f704aca89ec31f5c9beda17504815f7423870c3", size = 1317833, upload-time = "2026-03-12T14:08:23.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/17/150e9eecfa28cb23f7a0bfe83ae1486a11022b97fe6d12328b455784658d/opik-1.10.45-py3-none-any.whl", hash = "sha256:e8050d9e5e0d92ff587f156eacbdd02099897f39cfe79a98380b6c8ae9906b95", size = 1337714, upload-time = "2026-03-20T11:35:10.237Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5275,15 +5199,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-extra-types"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5523,16 +5447,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5968,15 +5892,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "resend"
|
||||
version = "2.23.0"
|
||||
version = "2.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/a3/20003e7d14604fef778bd30c69604df3560a657a95a5c29a9688610759b6/resend-2.23.0.tar.gz", hash = "sha256:df613827dcc40eb1c9de2e5ff600cd4081b89b206537dec8067af1a5016d23c7", size = 31416, upload-time = "2026-02-23T19:01:57.603Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/ff/6a4e5e758fc2145c6a7d8563934d8ee24bf96a0212d7ec7d1af1f155bb74/resend-2.26.0.tar.gz", hash = "sha256:957a6a59dc597ce27fbd6d5383220dd9cc497fab99d4f3d775c8a42a449a569e", size = 36238, upload-time = "2026-03-20T22:49:09.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/35/64df775b8cd95e89798fd7b1b7fcafa975b6b09f559c10c0650e65b33580/resend-2.23.0-py2.py3-none-any.whl", hash = "sha256:eca6d28a1ffd36c1fc489fa83cb6b511f384792c9f07465f7c92d96c8b4d5636", size = 52599, upload-time = "2026-02-23T19:01:55.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c2/f88d3299d97aa1d36a923d0846fe185fcf5355ca898c954b2e5a79f090b5/resend-2.26.0-py2.py3-none-any.whl", hash = "sha256:5e25a804a84a68df504f2ade5369ac37e0139e37788a1f20b66c88696595b4bc", size = 57699, upload-time = "2026-03-20T22:49:08.354Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6057,27 +5981,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.6"
|
||||
version = "0.15.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6116,14 +6040,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "scipy-stubs"
|
||||
version = "1.17.1.2"
|
||||
version = "1.17.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "optype", extra = ["numpy"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/43f681ffba42f363b7ed6b767fd215d1e26006578214ff8330586a11bf95/scipy_stubs-1.17.1.2.tar.gz", hash = "sha256:2ecadc8c87a3b61aaf7379d6d6b10f1038a829c53b9efe5b174fb97fc8b52237", size = 388354, upload-time = "2026-03-15T22:33:20.449Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/0b/ec4fe720c1202d9df729a3e9d9b7e4d2da9f6e7f28bd2877b7d0769f4f75/scipy_stubs-1.17.1.2-py3-none-any.whl", hash = "sha256:f19e8f5273dbe3b7ee6a9554678c3973b9695fa66b91f29206d00830a1536c06", size = 594377, upload-time = "2026-03-15T22:33:18.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6142,15 +6066,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.54.0"
|
||||
version = "2.55.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -6386,15 +6310,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6803,11 +6727,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-cachetools"
|
||||
version = "6.2.0.20251022"
|
||||
version = "6.2.0.20260317"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/a8/f9bcc7f1be63af43ef0170a773e2d88817bcc7c9d8769f2228c802826efe/types_cachetools-6.2.0.20251022.tar.gz", hash = "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef", size = 9608, upload-time = "2025-10-22T03:03:58.16Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/7f/16a4d8344c28193a5a74358028c2d2f753f0d9658dd98b9e1967c50045a2/types_cachetools-6.2.0.20260317.tar.gz", hash = "sha256:6d91855bcc944665897c125e720aa3c80aace929b77a64e796343701df4f61c6", size = 9812, upload-time = "2026-03-17T04:06:32.007Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2d/8d821ed80f6c2c5b427f650bf4dc25b80676ed63d03388e4b637d2557107/types_cachetools-6.2.0.20251022-py3-none-any.whl", hash = "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", size = 9341, upload-time = "2025-10-22T03:03:57.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9a/b00b23054934c4d569c19f7278c4fb32746cd36a64a175a216d3073a4713/types_cachetools-6.2.0.20260317-py3-none-any.whl", hash = "sha256:92fa9bc50e4629e31fca67ceb3fb1de71791e314fa16c0a0d2728724dc222c8b", size = 9346, upload-time = "2026-03-17T04:06:31.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6851,11 +6775,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-docutils"
|
||||
version = "0.22.3.20260316"
|
||||
version = "0.22.3.20260322"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6885,15 +6809,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-gevent"
|
||||
version = "25.9.0.20251228"
|
||||
version = "25.9.0.20260322"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "types-greenlet" },
|
||||
{ name = "types-psutil" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/f0/14a99ddcaa69b559fa7cec8c9de880b792bebb0b848ae865d94ea9058533/types_gevent-25.9.0.20260322.tar.gz", hash = "sha256:91257920845762f09753c08aa20fad1743ac13d2de8bcf23f4b8fe967d803732", size = 38241, upload-time = "2026-03-22T04:08:55.213Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0f/964440b57eb4ddb4aca03479a4093852e1ce79010d1c5967234e6f5d6bd9/types_gevent-25.9.0.20260322-py3-none-any.whl", hash = "sha256:21b3c269b3a20ecb0e4668289c63b97d21694d84a004ab059c1e32ab970eacc2", size = 55500, upload-time = "2026-03-22T04:08:54.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6976,11 +6900,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-openpyxl"
|
||||
version = "3.1.5.20260316"
|
||||
version = "3.1.5.20260322"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/38/32f8ee633dd66ca6d52b8853b9fd45dc3869490195a6ed435d5c868b9c2d/types_openpyxl-3.1.5.20260316.tar.gz", hash = "sha256:081dda9427ea1141e5649e3dcf630e7013a4cf254a5862a7e0a3f53c123b7ceb", size = 101318, upload-time = "2026-03-16T04:29:05.004Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/bf/15240de4d68192d2a1f385ef2f6f1ecb29b85d2f3791dd2e2d5b980be30f/types_openpyxl-3.1.5.20260322.tar.gz", hash = "sha256:a61d66ebe1e49697853c6db8e0929e1cda2c96755e71fb676ed7fc48dfdcf697", size = 101325, upload-time = "2026-03-22T04:08:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/df/b87ae6226ed7cc84b9e43119c489c7f053a9a25e209e0ebb5d84bc36fa37/types_openpyxl-3.1.5.20260316-py3-none-any.whl", hash = "sha256:38e7e125df520fb7eb72cb1129c9f024eb99ef9564aad2c27f68f080c26bcf2d", size = 166084, upload-time = "2026-03-16T04:29:03.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/b4/c14191b30bcb266365b124b2bb4e67ecd68425a78ba77ee026f33667daa9/types_openpyxl-3.1.5.20260322-py3-none-any.whl", hash = "sha256:2f515f0b0bbfb04bfb587de34f7522d90b5151a8da7bbbd11ecec4ca40f64238", size = 166102, upload-time = "2026-03-22T04:08:39.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7055,11 +6979,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.9.0.20260305"
|
||||
version = "2.9.0.20260323"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/02/f72df9ef5ffc4f959b83cb80c8aa03eb8718a43e563ecd99ccffe265fa89/types_python_dateutil-2.9.0.20260323.tar.gz", hash = "sha256:a107aef5841db41ace381dbbbd7e4945220fc940f7a72172a0be5a92d9ab7164", size = 16897, upload-time = "2026-03-23T04:15:14.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c1/b661838b97453e699a215451f2e22cee750eaaf4ea4619b34bdaf01221a4/types_python_dateutil-2.9.0.20260323-py3-none-any.whl", hash = "sha256:a23a50a07f6eb87e729d4cb0c2eb511c81761eeb3f505db2c1413be94aae8335", size = 18433, upload-time = "2026-03-23T04:15:13.683Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7073,11 +6997,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-pywin32"
|
||||
version = "311.0.0.20260316"
|
||||
version = "311.0.0.20260323"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/a8/b4652002a854fcfe5d272872a0ae2d5df0e9dc482e1a6dfb5e97b905b76f/types_pywin32-311.0.0.20260316.tar.gz", hash = "sha256:c136fa489fe6279a13bca167b750414e18d657169b7cf398025856dc363004e8", size = 329956, upload-time = "2026-03-16T04:28:57.366Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/cc/f03ddb7412ac2fc2238358b617c2d5919ba96812dff8d3081f3b2754bb83/types_pywin32-311.0.0.20260323.tar.gz", hash = "sha256:2e8dc6a59fedccbc51b241651ce1e8aa58488934f517debf23a9c6d0ff329b4b", size = 332263, upload-time = "2026-03-23T04:15:20.004Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/83/704698d93788cf1c2f5e236eae2b37f1b2152ef84dc66b4b83f6c7487b76/types_pywin32-311.0.0.20260316-py3-none-any.whl", hash = "sha256:abb643d50012386d697af49384cc0e6e475eab76b0ca2a7f93d480d0862b3692", size = 392959, upload-time = "2026-03-16T04:28:56.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/d786d5d8b846e3cbe1ee52da8945560b111c789b42c3771b2129b312ab94/types_pywin32-311.0.0.20260323-py3-none-any.whl", hash = "sha256:2f2b03fc72ae77ccbb0ee258da0f181c3a38bd8602f6e332e42587b3b0d5f095", size = 395435, upload-time = "2026-03-23T04:15:18.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7173,16 +7097,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-tensorflow"
|
||||
version = "2.18.0.20260224"
|
||||
version = "2.18.0.20260322"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "types-protobuf" },
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/4914c2fbc1cf8a8d1ef2a7c727bb6f694879be85edeee880a0c88e696af8/types_tensorflow-2.18.0.20260224.tar.gz", hash = "sha256:9b0ccc91c79c88791e43d3f80d6c879748fa0361409c5ff23c7ffe3709be00f2", size = 258786, upload-time = "2026-02-24T04:06:45.613Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/cb/81dfaa2680031a6e087bcdfaf1c0556371098e229aee541e21c81a381065/types_tensorflow-2.18.0.20260322.tar.gz", hash = "sha256:135dc6ca06cc647a002e1bca5c5c99516fde51efd08e46c48a9b1916fc5df07f", size = 259030, upload-time = "2026-03-22T04:09:14.069Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/1d/a1c3c60f0eb1a204500dbdc66e3d18aafabc86ad07a8eca71ea05bc8c5a8/types_tensorflow-2.18.0.20260224-py3-none-any.whl", hash = "sha256:6a25f5f41f3e06f28c1f65c6e09f484d4ba0031d6d8df83a39df9d890245eefc", size = 329746, upload-time = "2026-02-24T04:06:44.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/0c/a178061450b640e53577e2c423ad22bf5d3f692f6bfeeb12156d02b531ef/types_tensorflow-2.18.0.20260322-py3-none-any.whl", hash = "sha256:d8776b6daacdb279e64f105f9dcbc0b8e3544b9a2f2eb71ec6ea5955081f65e6", size = 329771, upload-time = "2026-03-22T04:09:12.844Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
440
docker/dify-env-sync.py
Executable file
440
docker/dify-env-sync.py
Executable file
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# ================================================================
|
||||
# Dify Environment Variables Synchronization Script
|
||||
#
|
||||
# Features:
|
||||
# - Synchronize latest settings from .env.example to .env
|
||||
# - Preserve custom settings in existing .env
|
||||
# - Add new environment variables
|
||||
# - Detect removed environment variables
|
||||
# - Create backup files
|
||||
# ================================================================
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ANSI color codes
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m" # No Color
|
||||
|
||||
|
||||
def supports_color() -> bool:
|
||||
"""Return True if the terminal supports ANSI color codes."""
|
||||
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
||||
|
||||
|
||||
def log_info(message: str) -> None:
|
||||
"""Print an informational message in blue."""
|
||||
if supports_color():
|
||||
print(f"{BLUE}[INFO]{NC} {message}")
|
||||
else:
|
||||
print(f"[INFO] {message}")
|
||||
|
||||
|
||||
def log_success(message: str) -> None:
|
||||
"""Print a success message in green."""
|
||||
if supports_color():
|
||||
print(f"{GREEN}[SUCCESS]{NC} {message}")
|
||||
else:
|
||||
print(f"[SUCCESS] {message}")
|
||||
|
||||
|
||||
def log_warning(message: str) -> None:
|
||||
"""Print a warning message in yellow to stderr."""
|
||||
if supports_color():
|
||||
print(f"{YELLOW}[WARNING]{NC} {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[WARNING] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
"""Print an error message in red to stderr."""
|
||||
if supports_color():
|
||||
print(f"{RED}[ERROR]{NC} {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[ERROR] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def parse_env_file(path: Path) -> dict[str, str]:
|
||||
"""Parse an .env-style file and return a mapping of key to raw value.
|
||||
|
||||
Lines that are blank or start with '#' (after optional whitespace) are
|
||||
skipped. Only lines containing '=' are considered variable definitions.
|
||||
|
||||
Args:
|
||||
path: Path to the .env file to parse.
|
||||
|
||||
Returns:
|
||||
Ordered dict mapping variable name to its value string.
|
||||
"""
|
||||
variables: dict[str, str] = {}
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.rstrip("\n")
|
||||
# Skip blank lines and comment lines
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key:
|
||||
variables[key] = value.strip()
|
||||
return variables
|
||||
|
||||
|
||||
def check_files(work_dir: Path) -> None:
|
||||
"""Verify required files exist; create .env from .env.example if absent.
|
||||
|
||||
Args:
|
||||
work_dir: Directory that must contain .env.example (and optionally .env).
|
||||
|
||||
Raises:
|
||||
SystemExit: If .env.example does not exist.
|
||||
"""
|
||||
log_info("Checking required files...")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
if not example_file.exists():
|
||||
log_error(".env.example file not found")
|
||||
sys.exit(1)
|
||||
|
||||
if not env_file.exists():
|
||||
log_warning(".env file does not exist. Creating from .env.example.")
|
||||
shutil.copy2(example_file, env_file)
|
||||
log_success(".env file created")
|
||||
|
||||
log_success("Required files verified")
|
||||
|
||||
|
||||
def create_backup(work_dir: Path) -> None:
|
||||
"""Create a timestamped backup of the current .env file.
|
||||
|
||||
Backups are placed in ``<work_dir>/env-backup/`` with the filename
|
||||
``.env.backup_<YYYYMMDD_HHMMSS>``.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing the .env file to back up.
|
||||
"""
|
||||
env_file = work_dir / ".env"
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
backup_dir = work_dir / "env-backup"
|
||||
if not backup_dir.exists():
|
||||
backup_dir.mkdir(parents=True)
|
||||
log_info(f"Created backup directory: {backup_dir}")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = backup_dir / f".env.backup_{timestamp}"
|
||||
shutil.copy2(env_file, backup_file)
|
||||
log_success(f"Backed up existing .env to {backup_file}")
|
||||
|
||||
|
||||
def analyze_value_change(current: str, recommended: str) -> str | None:
|
||||
"""Analyse what kind of change occurred between two env values.
|
||||
|
||||
Args:
|
||||
current: Value currently set in .env.
|
||||
recommended: Value present in .env.example.
|
||||
|
||||
Returns:
|
||||
A human-readable description string, or None when no analysis applies.
|
||||
"""
|
||||
use_colors = supports_color()
|
||||
|
||||
def colorize(color: str, text: str) -> str:
|
||||
return f"{color}{text}{NC}" if use_colors else text
|
||||
|
||||
if not current and recommended:
|
||||
return colorize(RED, " -> Setting from empty to recommended value")
|
||||
if current and not recommended:
|
||||
return colorize(RED, " -> Recommended value changed to empty")
|
||||
|
||||
# Numeric comparison
|
||||
if re.fullmatch(r"\d+", current) and re.fullmatch(r"\d+", recommended):
|
||||
cur_int, rec_int = int(current), int(recommended)
|
||||
if cur_int < rec_int:
|
||||
return colorize(BLUE, f" -> Numeric increase ({current} < {recommended})")
|
||||
if cur_int > rec_int:
|
||||
return colorize(YELLOW, f" -> Numeric decrease ({current} > {recommended})")
|
||||
return None
|
||||
|
||||
# Boolean comparison
|
||||
if current.lower() in {"true", "false"} and recommended.lower() in {"true", "false"}:
|
||||
if current.lower() != recommended.lower():
|
||||
return colorize(BLUE, f" -> Boolean value change ({current} -> {recommended})")
|
||||
return None
|
||||
|
||||
# URL / endpoint
|
||||
if current.startswith(("http://", "https://")) or recommended.startswith(("http://", "https://")):
|
||||
return colorize(BLUE, " -> URL/endpoint change")
|
||||
|
||||
# File path
|
||||
if current.startswith("/") or recommended.startswith("/"):
|
||||
return colorize(BLUE, " -> File path change")
|
||||
|
||||
# String length
|
||||
if len(current) != len(recommended):
|
||||
return colorize(YELLOW, f" -> String length change ({len(current)} -> {len(recommended)} characters)")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]:
|
||||
"""Find variables whose values differ between .env and .env.example.
|
||||
|
||||
Only variables present in *both* files are compared; new or removed
|
||||
variables are handled by separate functions.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
|
||||
Returns:
|
||||
Mapping of key -> (env_value, example_value) for every key whose
|
||||
values differ.
|
||||
"""
|
||||
log_info("Detecting differences between .env and .env.example...")
|
||||
|
||||
diffs: dict[str, tuple[str, str]] = {}
|
||||
for key, example_value in example_vars.items():
|
||||
if key in env_vars and env_vars[key] != example_value:
|
||||
diffs[key] = (env_vars[key], example_value)
|
||||
|
||||
if diffs:
|
||||
log_success(f"Detected differences in {len(diffs)} environment variables")
|
||||
show_differences_detail(diffs)
|
||||
else:
|
||||
log_info("No differences detected")
|
||||
|
||||
return diffs
|
||||
|
||||
|
||||
def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
|
||||
"""Print a formatted table of differing environment variables.
|
||||
|
||||
Args:
|
||||
diffs: Mapping of key -> (current_value, recommended_value).
|
||||
"""
|
||||
use_colors = supports_color()
|
||||
|
||||
log_info("")
|
||||
log_info("=== Environment Variable Differences ===")
|
||||
|
||||
if not diffs:
|
||||
log_info("No differences to display")
|
||||
return
|
||||
|
||||
for count, (key, (env_value, example_value)) in enumerate(diffs.items(), start=1):
|
||||
print()
|
||||
if use_colors:
|
||||
print(f"{YELLOW}[{count}] {key}{NC}")
|
||||
print(f" {GREEN}.env (current){NC} : {env_value}")
|
||||
print(f" {BLUE}.env.example (recommended){NC} : {example_value}")
|
||||
else:
|
||||
print(f"[{count}] {key}")
|
||||
print(f" .env (current) : {env_value}")
|
||||
print(f" .env.example (recommended) : {example_value}")
|
||||
|
||||
analysis = analyze_value_change(env_value, example_value)
|
||||
if analysis:
|
||||
print(analysis)
|
||||
|
||||
print()
|
||||
log_info("=== Difference Analysis Complete ===")
|
||||
log_info("Note: Consider changing to the recommended values above.")
|
||||
log_info("Current implementation preserves .env values.")
|
||||
print()
|
||||
|
||||
|
||||
def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]:
|
||||
"""Identify variables present in .env but absent from .env.example.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
|
||||
Returns:
|
||||
Sorted list of variable names that no longer appear in .env.example.
|
||||
"""
|
||||
log_info("Detecting removed environment variables...")
|
||||
|
||||
removed = sorted(set(env_vars) - set(example_vars))
|
||||
|
||||
if removed:
|
||||
log_warning("The following environment variables have been removed from .env.example:")
|
||||
for var in removed:
|
||||
log_warning(f" - {var}")
|
||||
log_warning("Consider manually removing these variables from .env")
|
||||
else:
|
||||
log_success("No removed environment variables found")
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None:
|
||||
"""Rewrite .env based on .env.example while preserving custom values.
|
||||
|
||||
The output file follows the exact line structure of .env.example
|
||||
(preserving comments, blank lines, and ordering). For every variable
|
||||
that exists in .env with a different value from the example, the
|
||||
current .env value is kept. Variables that are new in .env.example
|
||||
(not present in .env at all) are added with the example's default.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
env_vars: Parsed key/value pairs from the original .env.
|
||||
diffs: Keys whose .env values differ from .env.example (to preserve).
|
||||
"""
|
||||
log_info("Starting partial synchronization of .env file...")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
new_env_file = work_dir / ".env.new"
|
||||
|
||||
# Keys whose current .env value should override the example default
|
||||
preserved_keys: set[str] = set(diffs.keys())
|
||||
|
||||
preserved_count = 0
|
||||
updated_count = 0
|
||||
|
||||
env_var_pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
||||
|
||||
with example_file.open(encoding="utf-8") as src, new_env_file.open("w", encoding="utf-8") as dst:
|
||||
for line in src:
|
||||
raw_line = line.rstrip("\n")
|
||||
match = env_var_pattern.match(raw_line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
if key in preserved_keys:
|
||||
# Write the preserved value from .env
|
||||
dst.write(f"{key}={env_vars[key]}\n")
|
||||
log_info(f" Preserved: {key} (.env value)")
|
||||
preserved_count += 1
|
||||
else:
|
||||
# Use the example value (covers new vars and unchanged ones)
|
||||
dst.write(line if line.endswith("\n") else raw_line + "\n")
|
||||
updated_count += 1
|
||||
else:
|
||||
# Blank line, comment, or non-variable line — keep as-is
|
||||
dst.write(line if line.endswith("\n") else raw_line + "\n")
|
||||
|
||||
# Atomically replace the original .env
|
||||
try:
|
||||
new_env_file.replace(work_dir / ".env")
|
||||
except OSError as exc:
|
||||
log_error(f"Failed to replace .env file: {exc}")
|
||||
new_env_file.unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
|
||||
log_success("Successfully created new .env file")
|
||||
log_success("Partial synchronization of .env file completed")
|
||||
log_info(f" Preserved .env values: {preserved_count}")
|
||||
log_info(f" Updated to .env.example values: {updated_count}")
|
||||
|
||||
|
||||
def show_statistics(work_dir: Path) -> None:
|
||||
"""Print a summary of variable counts from both env files.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
"""
|
||||
log_info("Synchronization statistics:")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
example_count = len(parse_env_file(example_file)) if example_file.exists() else 0
|
||||
env_count = len(parse_env_file(env_file)) if env_file.exists() else 0
|
||||
|
||||
log_info(f" .env.example environment variables: {example_count}")
|
||||
log_info(f" .env environment variables: {env_count}")
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Build and return the CLI argument parser.
|
||||
|
||||
Returns:
|
||||
Configured ArgumentParser instance.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="dify-env-sync",
|
||||
description=(
|
||||
"Synchronize .env with .env.example: add new variables, "
|
||||
"preserve custom values, and report removed variables."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" # Run from the docker/ directory (default)\n"
|
||||
" python dify-env-sync.py\n\n"
|
||||
" # Specify a custom working directory\n"
|
||||
" python dify-env-sync.py --dir /path/to/docker\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dir",
|
||||
metavar="DIRECTORY",
|
||||
default=".",
|
||||
help="Working directory containing .env and .env.example (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip creating a timestamped backup of the existing .env file",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Orchestrate the complete environment variable synchronization process."""
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
work_dir = Path(args.dir).resolve()
|
||||
|
||||
log_info("=== Dify Environment Variables Synchronization Script ===")
|
||||
log_info(f"Execution started: {datetime.now()}")
|
||||
log_info(f"Working directory: {work_dir}")
|
||||
|
||||
# 1. Verify prerequisites
|
||||
check_files(work_dir)
|
||||
|
||||
# 2. Backup existing .env
|
||||
if not args.no_backup:
|
||||
create_backup(work_dir)
|
||||
|
||||
# 3. Parse both files
|
||||
env_vars = parse_env_file(work_dir / ".env")
|
||||
example_vars = parse_env_file(work_dir / ".env.example")
|
||||
|
||||
# 4. Report differences (values that changed in the example)
|
||||
diffs = detect_differences(env_vars, example_vars)
|
||||
|
||||
# 5. Report variables removed from the example
|
||||
detect_removed_variables(env_vars, example_vars)
|
||||
|
||||
# 6. Rewrite .env
|
||||
sync_env_file(work_dir, env_vars, diffs)
|
||||
|
||||
# 7. Print summary statistics
|
||||
show_statistics(work_dir)
|
||||
|
||||
log_success("=== Synchronization process completed successfully ===")
|
||||
log_info(f"Execution finished: {datetime.now()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
186
docs/eu-ai-act-compliance.md
Normal file
186
docs/eu-ai-act-compliance.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# EU AI Act Compliance Guide for Dify Deployers
|
||||
|
||||
Dify is an LLMOps platform for building RAG pipelines, agents, and AI workflows. If you deploy Dify in the EU — whether self-hosted or using a cloud provider — the EU AI Act applies to your deployment. This guide covers what the regulation requires and how Dify's architecture maps to those requirements.
|
||||
|
||||
## Is your system in scope?
|
||||
|
||||
The detailed obligations in Articles 12, 13, and 14 only apply to **high-risk AI systems** as defined in Annex III of the EU AI Act. A Dify application is high-risk if it is used for:
|
||||
|
||||
- **Recruitment and HR** — screening candidates, evaluating employee performance, allocating tasks
|
||||
- **Credit scoring and insurance** — assessing creditworthiness or setting premiums
|
||||
- **Law enforcement** — profiling, criminal risk assessment, border control
|
||||
- **Critical infrastructure** — managing energy, water, transport, or telecommunications systems
|
||||
- **Education assessment** — grading students, determining admissions
|
||||
- **Essential public services** — evaluating eligibility for benefits, housing, or emergency services
|
||||
|
||||
Most Dify deployments (customer-facing chatbots, internal knowledge bases, content generation workflows) are **not** high-risk. If your Dify application does not fall into one of the categories above:
|
||||
|
||||
- **Article 50** (end-user transparency) still applies if users interact with your application directly. See the [Article 50 section](#article-50-end-user-transparency) below.
|
||||
- **GDPR** still applies if you process personal data. See the [GDPR section](#gdpr-considerations) below.
|
||||
- The high-risk obligations (Articles 9-15) are less likely to apply, but risk classification is context-dependent. **Do not self-classify without legal review.** Focus on Article 50 (transparency) and GDPR (data protection) as your baseline obligations.
|
||||
|
||||
If you are unsure whether your use case qualifies as high-risk, consult a qualified legal professional before proceeding.
|
||||
|
||||
## Self-hosted vs cloud: different compliance profiles
|
||||
|
||||
| Deployment | Your role | Dify's role | Who handles compliance? |
|
||||
|-----------|----------|-------------|------------------------|
|
||||
| **Self-hosted** | Provider and deployer | Framework provider — obligations under Article 25 apply only if Dify is placed on the market or put into service as part of a complete AI system bearing its name or trademark | You |
|
||||
| **Dify Cloud** | Deployer | Provider and processor | Shared — Dify handles SOC 2 and GDPR for the platform; you handle AI Act obligations for your specific use case |
|
||||
|
||||
Dify Cloud already has SOC 2 Type II and GDPR compliance for the platform itself. But the EU AI Act adds obligations specific to AI systems that SOC 2 does not cover: risk classification, technical documentation, transparency, and human oversight.
|
||||
|
||||
## Supported providers and services
|
||||
|
||||
Dify integrates with a broad range of AI providers and data stores. The following are the key ones relevant to compliance:
|
||||
|
||||
- **AI providers:** HuggingFace (core), plus integrations with OpenAI, Anthropic, Google, and 100+ models via provider plugins
|
||||
- **Model identifiers include:** gpt-4o, gpt-3.5-turbo, claude-3-opus, gemini-2.5-flash, whisper-1, and others
|
||||
- **Vector database connections:** Extensive RAG infrastructure supporting numerous vector stores
|
||||
|
||||
Dify's plugin architecture means actual provider usage depends on your configuration. Document which providers and models are active in your deployment.
|
||||
|
||||
## Data flow diagram
|
||||
|
||||
A typical Dify RAG deployment:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
USER((User)) -->|query| DIFY[Dify Platform]
|
||||
DIFY -->|prompts| LLM([LLM Provider])
|
||||
LLM -->|responses| DIFY
|
||||
DIFY -->|documents| EMBED([Embedding Model])
|
||||
EMBED -->|vectors| DIFY
|
||||
DIFY -->|store/retrieve| VS[(Vector Store)]
|
||||
DIFY -->|knowledge| KB[(Knowledge Base)]
|
||||
DIFY -->|response| USER
|
||||
|
||||
classDef processor fill:#60a5fa,stroke:#1e40af,color:#000
|
||||
classDef controller fill:#4ade80,stroke:#166534,color:#000
|
||||
classDef app fill:#a78bfa,stroke:#5b21b6,color:#000
|
||||
classDef user fill:#f472b6,stroke:#be185d,color:#000
|
||||
|
||||
class USER user
|
||||
class DIFY app
|
||||
class LLM processor
|
||||
class EMBED processor
|
||||
class VS controller
|
||||
class KB controller
|
||||
```
|
||||
|
||||
**GDPR roles** (providers are typically processors for customer-submitted data, but the exact role depends on each provider's terms of service and processing purpose; deployers should review each provider's DPA):
|
||||
- **Cloud LLM providers (OpenAI, Anthropic, Google)** typically act as processors — requires DPA.
|
||||
- **Cloud embedding services** typically act as processors — requires DPA.
|
||||
- **Self-hosted vector stores (Weaviate, Qdrant, pgvector):** Your organization remains the controller — no third-party transfer.
|
||||
- **Cloud vector stores (Pinecone, Zilliz Cloud)** typically act as processors — requires DPA.
|
||||
- **Knowledge base documents:** Your organization is the controller — stored in your infrastructure.
|
||||
|
||||
## Article 11: Technical documentation
|
||||
|
||||
High-risk systems need Annex IV documentation. For Dify deployments, key sections include:
|
||||
|
||||
| Section | What Dify provides | What you must document |
|
||||
|---------|-------------------|----------------------|
|
||||
| General description | Platform capabilities, supported models | Your specific use case, intended users, deployment context |
|
||||
| Development process | Dify's architecture, plugin system | Your RAG pipeline design, prompt engineering, knowledge base curation |
|
||||
| Monitoring | Dify's built-in logging and analytics | Your monitoring plan, alert thresholds, incident response |
|
||||
| Performance metrics | Dify's evaluation features | Your accuracy benchmarks, quality thresholds, bias testing |
|
||||
| Risk management | — | Risk assessment for your specific use case |
|
||||
|
||||
Some sections can be derived from Dify's architecture and your deployment configuration, as shown in the table above. The remaining sections require your input.
|
||||
|
||||
## Article 12: Record-keeping
|
||||
|
||||
Dify's built-in logging covers several Article 12 requirements:
|
||||
|
||||
| Requirement | Dify Feature | Status |
|
||||
|------------|-------------|--------|
|
||||
| Conversation logs | Full conversation history with timestamps | **Covered** |
|
||||
| Model tracking | Model name recorded per interaction | **Covered** |
|
||||
| Token usage | Token counts per message | **Covered** |
|
||||
| Cost tracking | Cost per conversation (if provider reports it) | **Partial** |
|
||||
| Document retrieval | RAG source documents logged | **Covered** |
|
||||
| User identification | User session tracking | **Covered** |
|
||||
| Error logging | Failed generation logs | **Covered** |
|
||||
| Data retention | Configurable | **Your responsibility** |
|
||||
|
||||
**Retention periods:** The required retention period depends on your role under the Act. Article 18 requires **providers** of high-risk systems to retain logs and technical documentation for **10 years** after market placement. Article 26(6) requires **deployers** to retain logs for at least **6 months**. If you self-host Dify and have substantially modified the system, you may be classified as a provider rather than a deployer. Confirm the applicable retention period with legal counsel.
|
||||
|
||||
## Article 13: Transparency to deployers
|
||||
|
||||
Article 13 requires providers of high-risk AI systems to supply deployers with the information needed to understand and operate the system correctly. This is a **documentation obligation**, not a logging obligation. For Dify deployments, this means the upstream LLM and embedding providers must give you:
|
||||
|
||||
- Instructions for use, including intended purpose and known limitations
|
||||
- Accuracy metrics and performance benchmarks
|
||||
- Known or foreseeable risks and residual risks after mitigation
|
||||
- Technical specifications: input/output formats, training data characteristics, model architecture details
|
||||
|
||||
As a deployer, collect model cards, system documentation, and accuracy reports from each AI provider your Dify application uses. Maintain these as part of your Annex IV technical documentation.
|
||||
|
||||
Dify's platform features provide **supporting evidence** that can inform Article 13 documentation, but they do not satisfy Article 13 on their own:
|
||||
- **Source attribution** — Dify's RAG citation feature shows which documents informed the response, supporting deployer-side auditing
|
||||
- **Model identification** — Dify logs which LLM model generates responses, providing evidence for system documentation
|
||||
- **Conversation logs** — execution history helps compile performance and behavior evidence
|
||||
|
||||
You must independently produce system documentation covering how your specific Dify deployment uses AI, its intended purpose, performance characteristics, and residual risks.
|
||||
|
||||
## Article 50: End-user transparency
|
||||
|
||||
Article 50 requires deployers to inform end users that they are interacting with an AI system. This is a separate obligation from Article 13 and applies even to limited-risk systems.
|
||||
|
||||
For Dify applications serving end users:
|
||||
|
||||
1. **Disclose AI involvement** — tell users they are interacting with an AI system
|
||||
2. **AI-generated content labeling** — identify AI-generated content as such (e.g., clear labeling in the UI)
|
||||
|
||||
Dify's "citation" feature also supports end-user transparency by showing users which knowledge base documents informed the answer.
|
||||
|
||||
> **Note:** Article 50 applies to chatbots and systems interacting directly with natural persons. It has a separate scope from the high-risk designation under Annex III — it applies even to limited-risk systems.
|
||||
|
||||
## Article 14: Human oversight
|
||||
|
||||
Article 14 requires that high-risk AI systems be designed so that natural persons can effectively oversee them. Dify provides **automated technical safeguards** that support human oversight, but they are not a substitute for it:
|
||||
|
||||
| Dify Feature | What It Does | Oversight Role |
|
||||
|-------------|-------------|----------------|
|
||||
| Annotation/feedback system | Human review of AI outputs | **Direct oversight** — humans evaluate and correct AI responses |
|
||||
| Content moderation | Built-in filtering before responses reach users | **Automated safeguard** — reduces harmful outputs but does not replace human judgment on edge cases |
|
||||
| Rate limiting | Controls on API usage | **Automated safeguard** — bounds system behavior, supports overseer's ability to maintain control |
|
||||
| Workflow control | Insert human review steps between AI generation and output | **Oversight enabler** — allows building approval gates into the pipeline |
|
||||
|
||||
These automated controls are necessary building blocks, but Article 14 compliance requires **human oversight procedures** on top of them:
|
||||
- **Escalation procedures** — define what happens when moderation triggers or edge cases arise (who is notified, what action is taken)
|
||||
- **Human review pipeline** — for high-stakes decisions, route AI outputs to a qualified person before they take effect
|
||||
- **Override mechanism** — a human must be able to halt AI responses or override the system's output
|
||||
- **Competence requirements** — the human overseer must understand the system's capabilities, limitations, and the context of its outputs
|
||||
|
||||
### Recommended pattern
|
||||
|
||||
For high-risk use cases (HR, legal, medical), configure your Dify workflow to require human approval before the AI response is delivered to the end user or acted upon.
|
||||
|
||||
## Knowledge base compliance
|
||||
|
||||
Dify's knowledge base feature has specific compliance implications:
|
||||
|
||||
1. **Data provenance:** Document where your knowledge base documents come from. Article 10 requires data governance for training data; knowledge bases are analogous.
|
||||
2. **Update tracking:** When you add, remove, or update documents in the knowledge base, log the change. The AI system's behavior changes with its knowledge base.
|
||||
3. **PII in documents:** If knowledge base documents contain personal data, GDPR applies to the entire RAG pipeline. Implement access controls and consider PII redaction before indexing.
|
||||
4. **Copyright:** Ensure you have the right to use the documents in your knowledge base for AI-assisted generation.
|
||||
|
||||
## GDPR considerations
|
||||
|
||||
1. **Legal basis** (Article 6): Document why AI processing of user queries is necessary
|
||||
2. **Data Processing Agreements** (Article 28): Required for each cloud LLM and embedding provider
|
||||
3. **Data minimization:** Only include necessary context in prompts; avoid sending entire documents when a relevant excerpt suffices
|
||||
4. **Right to erasure:** If a user requests deletion, ensure their conversations are removed from Dify's logs AND any vector store entries derived from their data
|
||||
5. **Cross-border transfers:** Providers based outside the EEA — including US-based providers (OpenAI, Anthropic), and any other non-EEA providers you route to — require Standard Contractual Clauses (SCCs) or equivalent safeguards under Chapter V of the GDPR. Review each provider's transfer mechanism individually.
|
||||
|
||||
## Resources
|
||||
|
||||
- [EU AI Act full text](https://artificialintelligenceact.eu/)
|
||||
- [Dify documentation](https://docs.dify.ai/)
|
||||
- [Dify SOC 2 compliance](https://dify.ai/trust)
|
||||
|
||||
---
|
||||
|
||||
*This is not legal advice. Consult a qualified professional for compliance decisions.*
|
||||
@@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({
|
||||
<span data-testid="select-value">{value}</span>
|
||||
<span data-testid="select-items-count">{items.length}</span>
|
||||
{items.map((item: MockSelectItem) => (
|
||||
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
|
||||
<button type="button" key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -78,6 +78,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
|
||||
<Modal
|
||||
isShow
|
||||
onClose={noop}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="!w-[640px] !max-w-none !p-8 !pb-6"
|
||||
>
|
||||
<div className="mb-2 text-xl font-semibold text-text-primary">
|
||||
|
||||
@@ -69,7 +69,7 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[102] w-[calc(100%-32px)] max-w-[576px]">
|
||||
<PortalToFollowElemContent className="z-[1002] w-[calc(100%-32px)] max-w-[576px]">
|
||||
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
|
||||
@@ -84,7 +84,7 @@ const Configure = ({
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[61]">
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
|
||||
{
|
||||
!!canOAuth && (
|
||||
@@ -104,7 +104,7 @@ const Configure = ({
|
||||
}
|
||||
{
|
||||
!!canApiKey && !!canOAuth && (
|
||||
<div className="system-2xs-medium-uppercase flex h-4 items-center p-2 text-text-quaternary">
|
||||
<div className="flex h-4 items-center p-2 text-text-quaternary system-2xs-medium-uppercase">
|
||||
<div className="mr-2 h-[1px] grow bg-gradient-to-l from-[rgba(16,24,40,0.08)]" />
|
||||
OR
|
||||
<div className="ml-2 h-[1px] grow bg-gradient-to-r from-[rgba(16,24,40,0.08)]" />
|
||||
|
||||
@@ -39,7 +39,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('auth.setDefault', { ns: 'plugin' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -51,7 +51,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.rename', { ns: 'common' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -66,7 +66,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.edit', { ns: 'common' })}</div>
|
||||
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -81,7 +81,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold mb-1 text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -98,7 +98,7 @@ const Operator = ({
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
<div className="text-text-secondary system-sm-semibold">
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ const Operator = ({
|
||||
items={items}
|
||||
secondItems={secondItems}
|
||||
onSelect={handleSelect}
|
||||
popupClassName="z-[61]"
|
||||
popupClassName="z-[1002]"
|
||||
triggerProps={{
|
||||
size: 'l',
|
||||
}}
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDataSourceIntegrates, useInvalidDataSourceIntegrates, useNotionConnection } from '@/service/use-common'
|
||||
import DataSourceNotion from '../index'
|
||||
|
||||
/**
|
||||
* DataSourceNotion Component Tests
|
||||
* Using Unit approach with real Panel and sibling components to test Notion integration logic.
|
||||
*/
|
||||
|
||||
type MockQueryResult<T> = UseQueryResult<T, Error>
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
syncDataSourceNotion: vi.fn(),
|
||||
updateDataSourceNotionAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useDataSourceIntegrates: vi.fn(),
|
||||
useNotionConnection: vi.fn(),
|
||||
useInvalidDataSourceIntegrates: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('DataSourceNotion Component', () => {
|
||||
const mockWorkspaces: TDataSourceNotion[] = [
|
||||
{
|
||||
id: 'ws-1',
|
||||
provider: 'notion',
|
||||
is_bound: true,
|
||||
source_info: {
|
||||
workspace_name: 'Workspace 1',
|
||||
workspace_icon: 'https://example.com/icon-1.png',
|
||||
workspace_id: 'notion-ws-1',
|
||||
total: 10,
|
||||
pages: [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const baseAppContext: AppContextValue = {
|
||||
userProfile: { id: 'test-user-id', name: 'test-user', email: 'test@example.com', avatar: '', avatar_url: '', is_password_set: true },
|
||||
mutateUserProfile: vi.fn(),
|
||||
currentWorkspace: { id: 'ws-id', name: 'Workspace', plan: 'basic', status: 'normal', created_at: 0, role: 'owner', providers: [], trial_credits: 0, trial_credits_used: 0, next_credit_reset_date: 0 },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo: { current_version: '0.1.0', latest_version: '0.1.1', version: '0.1.1', release_date: '', release_notes: '', can_auto_update: false, current_env: 'test' },
|
||||
useSelector: vi.fn(),
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
const mockQuerySuccess = <T,>(data: T): MockQueryResult<T> => ({ data, isSuccess: true, isError: false, isLoading: false, isPending: false, status: 'success', error: null, fetchStatus: 'idle' } as any)
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
const mockQueryPending = <T,>(): MockQueryResult<T> => ({ data: undefined, isSuccess: false, isError: false, isLoading: true, isPending: true, status: 'pending', error: null, fetchStatus: 'fetching' } as any)
|
||||
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useAppContext).mockReturnValue(baseAppContext)
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [] }))
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQueryPending())
|
||||
vi.mocked(useInvalidDataSourceIntegrates).mockReturnValue(vi.fn())
|
||||
|
||||
const locationMock = { href: '', assign: vi.fn() }
|
||||
Object.defineProperty(window, 'location', { value: locationMock, writable: true, configurable: true })
|
||||
|
||||
// Clear document body to avoid toast leaks between tests
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
const getWorkspaceItem = (name: string) => {
|
||||
const nameEl = screen.getByText(name)
|
||||
return (nameEl.closest('div[class*="workspace-item"]') || nameEl.parentElement) as HTMLElement
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render with no workspaces initially and call integration hook', () => {
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.notion.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
|
||||
})
|
||||
|
||||
it('should render with provided workspaces and pass initialData to hook', () => {
|
||||
// Arrange
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion workspaces={mockWorkspaces} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workspace 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.dataSource.notion.connected')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'https://example.com/icon-1.png')
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: mockWorkspaces } })
|
||||
})
|
||||
|
||||
it('should handle workspaces prop being an empty array', () => {
|
||||
// Act
|
||||
render(<DataSourceNotion workspaces={[]} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: [] } })
|
||||
})
|
||||
|
||||
it('should handle optional workspaces configurations', () => {
|
||||
// Branch: workspaces passed as undefined
|
||||
const { rerender } = render(<DataSourceNotion workspaces={undefined} />)
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
|
||||
|
||||
// Branch: workspaces passed as null
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
rerender(<DataSourceNotion workspaces={null as any} />)
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
|
||||
|
||||
// Branch: workspaces passed as []
|
||||
rerender(<DataSourceNotion workspaces={[]} />)
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: [] } })
|
||||
})
|
||||
|
||||
it('should handle cases where integrates data is loading or broken', () => {
|
||||
// Act (Loading)
|
||||
const { rerender } = render(<DataSourceNotion />)
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQueryPending())
|
||||
rerender(<DataSourceNotion />)
|
||||
// Assert
|
||||
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
|
||||
|
||||
// Act (Broken)
|
||||
const brokenData = {} as { data: TDataSourceNotion[] }
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess(brokenData))
|
||||
rerender(<DataSourceNotion />)
|
||||
// Assert
|
||||
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle integrates being nullish', () => {
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: undefined, isSuccess: true } as any)
|
||||
render(<DataSourceNotion />)
|
||||
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle integrates data being nullish', () => {
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: { data: null }, isSuccess: true } as any)
|
||||
render(<DataSourceNotion />)
|
||||
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle integrates data being valid', () => {
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: { data: [{ id: '1', is_bound: true, source_info: { workspace_name: 'W', workspace_icon: 'https://example.com/i.png', total: 1, pages: [] } }] }, isSuccess: true } as any)
|
||||
render(<DataSourceNotion />)
|
||||
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cover all possible falsy/nullish branches for integrates and workspaces', () => {
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
const { rerender } = render(<DataSourceNotion workspaces={null as any} />)
|
||||
|
||||
const integratesCases = [
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ data: null },
|
||||
{ data: undefined },
|
||||
{ data: [] },
|
||||
{ data: [mockWorkspaces[0]] },
|
||||
{ data: false },
|
||||
{ data: 0 },
|
||||
{ data: '' },
|
||||
123,
|
||||
'string',
|
||||
false,
|
||||
]
|
||||
|
||||
integratesCases.forEach((val) => {
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: val, isSuccess: true } as any)
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
rerender(<DataSourceNotion workspaces={null as any} />)
|
||||
})
|
||||
|
||||
expect(useDataSourceIntegrates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Permissions', () => {
|
||||
it('should pass readOnly as false when user is a manager', () => {
|
||||
// Arrange
|
||||
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: true })
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.notion.title').closest('div')).not.toHaveClass('grayscale')
|
||||
})
|
||||
|
||||
it('should pass readOnly as true when user is NOT a manager', () => {
|
||||
// Arrange
|
||||
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: false })
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.connect')).toHaveClass('opacity-50', 'grayscale')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configure and Auth Actions', () => {
|
||||
it('should handle configure action when user is workspace manager', () => {
|
||||
// Arrange
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.dataSource.connect'))
|
||||
|
||||
// Assert
|
||||
expect(useNotionConnection).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should block configure action when user is NOT workspace manager', () => {
|
||||
// Arrange
|
||||
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: false })
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.dataSource.connect'))
|
||||
|
||||
// Assert
|
||||
expect(useNotionConnection).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should redirect if auth URL is available when "Auth Again" is clicked', async () => {
|
||||
// Arrange
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http://auth-url' }))
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Act
|
||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
fireEvent.click(authAgainBtn)
|
||||
|
||||
// Assert
|
||||
expect(window.location.href).toBe('http://auth-url')
|
||||
})
|
||||
|
||||
it('should trigger connection flow if URL is missing when "Auth Again" is clicked', async () => {
|
||||
// Arrange
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Act
|
||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
fireEvent.click(authAgainBtn)
|
||||
|
||||
// Assert
|
||||
expect(useNotionConnection).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Side Effects (Redirection and Toast)', () => {
|
||||
it('should redirect automatically when connection data returns an http URL', async () => {
|
||||
// Arrange
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http://redirect-url' }))
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('http://redirect-url')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show toast notification when connection data is "internal"', async () => {
|
||||
// Arrange
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'internal' }))
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('common.dataSource.notion.integratedAlert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle various data types and missing properties in connection data correctly', async () => {
|
||||
// Arrange & Act (Unknown string)
|
||||
const { rerender } = render(<DataSourceNotion />)
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'unknown' }))
|
||||
rerender(<DataSourceNotion />)
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('')
|
||||
expect(screen.queryByText('common.dataSource.notion.integratedAlert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Act (Broken object)
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({} as any))
|
||||
rerender(<DataSourceNotion />)
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('')
|
||||
})
|
||||
|
||||
// Act (Non-string)
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 123 } as any))
|
||||
rerender(<DataSourceNotion />)
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect if data starts with "http" even if it is just "http"', async () => {
|
||||
// Arrange
|
||||
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http' }))
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('http')
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip side effect logic if connection data is an object but missing the "data" property', async () => {
|
||||
// Arrange
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue({} as any)
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip side effect logic if data.data is falsy', async () => {
|
||||
// Arrange
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue({ data: { data: null } } as any)
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Additional Action Edge Cases', () => {
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ data: undefined },
|
||||
{ data: null },
|
||||
{ data: '' },
|
||||
{ data: 0 },
|
||||
{ data: false },
|
||||
{ data: 'http' },
|
||||
{ data: 'internal' },
|
||||
{ data: 'unknown' },
|
||||
])('should cover connection data branch: %s', async (val) => {
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||
/* eslint-disable-next-line ts/no-explicit-any */
|
||||
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
|
||||
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Trigger handleAuthAgain with these values
|
||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
fireEvent.click(authAgainBtn)
|
||||
|
||||
expect(useNotionConnection).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases in Workspace Data', () => {
|
||||
it('should render correctly with missing source_info optional fields', async () => {
|
||||
// Arrange
|
||||
const workspaceWithMissingInfo: TDataSourceNotion = {
|
||||
id: 'ws-2',
|
||||
provider: 'notion',
|
||||
is_bound: false,
|
||||
source_info: { workspace_name: 'Workspace 2', workspace_id: 'notion-ws-2', workspace_icon: null, pages: [] },
|
||||
}
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [workspaceWithMissingInfo] }))
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Workspace 2')).toBeInTheDocument()
|
||||
|
||||
const workspaceItem = getWorkspaceItem('Workspace 2')
|
||||
const actionBtn = within(workspaceItem).getByRole('button')
|
||||
fireEvent.click(actionBtn)
|
||||
|
||||
expect(await screen.findByText('0 common.dataSource.notion.pagesAuthorized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display inactive status correctly for unbound workspaces', () => {
|
||||
// Arrange
|
||||
const inactiveWS: TDataSourceNotion = {
|
||||
id: 'ws-3',
|
||||
provider: 'notion',
|
||||
is_bound: false,
|
||||
source_info: { workspace_name: 'Workspace 3', workspace_icon: 'https://example.com/icon-3.png', workspace_id: 'notion-ws-3', total: 5, pages: [] },
|
||||
}
|
||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [inactiveWS] }))
|
||||
|
||||
// Act
|
||||
render(<DataSourceNotion />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.notion.disconnected')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDataSourceIntegrates, useNotionConnection } from '@/service/use-common'
|
||||
import Panel from '../panel'
|
||||
import { DataSourceType } from '../panel/types'
|
||||
|
||||
const Icon: FC<{
|
||||
src: string
|
||||
name: string
|
||||
className: string
|
||||
}> = ({ src, name, className }) => {
|
||||
return (
|
||||
<NotionIcon
|
||||
src={src}
|
||||
name={name}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
type Props = {
|
||||
workspaces?: TDataSourceNotion[]
|
||||
}
|
||||
|
||||
const DataSourceNotion: FC<Props> = ({
|
||||
workspaces,
|
||||
}) => {
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [canConnectNotion, setCanConnectNotion] = useState(false)
|
||||
const { data: integrates } = useDataSourceIntegrates({
|
||||
initialData: workspaces ? { data: workspaces } : undefined,
|
||||
})
|
||||
const { data } = useNotionConnection(canConnectNotion)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedWorkspaces = integrates?.data ?? []
|
||||
const connected = !!resolvedWorkspaces.length
|
||||
|
||||
const handleConnectNotion = () => {
|
||||
if (!isCurrentWorkspaceManager)
|
||||
return
|
||||
|
||||
setCanConnectNotion(true)
|
||||
}
|
||||
|
||||
const handleAuthAgain = () => {
|
||||
if (data?.data)
|
||||
window.location.href = data.data
|
||||
else
|
||||
setCanConnectNotion(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data && 'data' in data) {
|
||||
if (data.data && typeof data.data === 'string' && data.data.startsWith('http')) {
|
||||
window.location.href = data.data
|
||||
}
|
||||
else if (data.data === 'internal') {
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('dataSource.notion.integratedAlert', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [data, t])
|
||||
|
||||
return (
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={connected}
|
||||
onConfigure={handleConnectNotion}
|
||||
readOnly={!isCurrentWorkspaceManager}
|
||||
isSupportList
|
||||
configuredList={resolvedWorkspaces.map(workspace => ({
|
||||
id: workspace.id,
|
||||
logo: ({ className }: { className: string }) => (
|
||||
<Icon
|
||||
src={workspace.source_info.workspace_icon!}
|
||||
name={workspace.source_info.workspace_name}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
name: workspace.source_info.workspace_name,
|
||||
isActive: workspace.is_bound,
|
||||
notionConfig: {
|
||||
total: workspace.source_info.total || 0,
|
||||
},
|
||||
}))}
|
||||
onRemove={noop} // handled in operation/index.tsx
|
||||
notionActions={{
|
||||
onChangeAuthorizedPage: handleAuthAgain,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(DataSourceNotion)
|
||||
@@ -1,137 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
|
||||
import { useInvalidDataSourceIntegrates } from '@/service/use-common'
|
||||
import Operate from '../index'
|
||||
|
||||
/**
|
||||
* Operate Component (Notion) Tests
|
||||
* This component provides actions like Sync, Change Pages, and Remove for Notion data sources.
|
||||
*/
|
||||
|
||||
// Mock services and toast
|
||||
vi.mock('@/service/common', () => ({
|
||||
syncDataSourceNotion: vi.fn(),
|
||||
updateDataSourceNotionAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useInvalidDataSourceIntegrates: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Operate Component (Notion)', () => {
|
||||
const mockPayload = {
|
||||
id: 'test-notion-id',
|
||||
total: 5,
|
||||
}
|
||||
const mockOnAuthAgain = vi.fn()
|
||||
const mockInvalidate = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useInvalidDataSourceIntegrates).mockReturnValue(mockInvalidate)
|
||||
vi.mocked(syncDataSourceNotion).mockResolvedValue({ result: 'success' })
|
||||
vi.mocked(updateDataSourceNotionAction).mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the menu button initially', () => {
|
||||
// Act
|
||||
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
|
||||
|
||||
// Assert
|
||||
const menuButton = within(container).getByRole('button')
|
||||
expect(menuButton).toBeInTheDocument()
|
||||
expect(menuButton).not.toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should open the menu and show all options when clicked', async () => {
|
||||
// Arrange
|
||||
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
|
||||
const menuButton = within(container).getByRole('button')
|
||||
|
||||
// Act
|
||||
fireEvent.click(menuButton)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('common.dataSource.notion.changeAuthorizedPages')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.dataSource.notion.sync')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.dataSource.notion.remove')).toBeInTheDocument()
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/common.dataSource.notion.pagesAuthorized/)).toBeInTheDocument()
|
||||
expect(menuButton).toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu Actions', () => {
|
||||
it('should call onAuthAgain when Change Authorized Pages is clicked', async () => {
|
||||
// Arrange
|
||||
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
|
||||
fireEvent.click(within(container).getByRole('button'))
|
||||
const option = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||
|
||||
// Act
|
||||
fireEvent.click(option)
|
||||
|
||||
// Assert
|
||||
expect(mockOnAuthAgain).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleSync, show success toast, and invalidate cache when Sync is clicked', async () => {
|
||||
// Arrange
|
||||
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
|
||||
fireEvent.click(within(container).getByRole('button'))
|
||||
const syncBtn = await screen.findByText('common.dataSource.notion.sync')
|
||||
|
||||
// Act
|
||||
fireEvent.click(syncBtn)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(syncDataSourceNotion).toHaveBeenCalledWith({
|
||||
url: `/oauth/data-source/notion/${mockPayload.id}/sync`,
|
||||
})
|
||||
})
|
||||
expect((await screen.findAllByText('common.api.success')).length).toBeGreaterThan(0)
|
||||
expect(mockInvalidate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleRemove, show success toast, and invalidate cache when Remove is clicked', async () => {
|
||||
// Arrange
|
||||
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
|
||||
fireEvent.click(within(container).getByRole('button'))
|
||||
const removeBtn = await screen.findByText('common.dataSource.notion.remove')
|
||||
|
||||
// Act
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(updateDataSourceNotionAction).toHaveBeenCalledWith({
|
||||
url: `/data-source/integrates/${mockPayload.id}/disable`,
|
||||
})
|
||||
})
|
||||
expect((await screen.findAllByText('common.api.success')).length).toBeGreaterThan(0)
|
||||
expect(mockInvalidate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Transitions', () => {
|
||||
it('should toggle the open class on the button based on menu visibility', async () => {
|
||||
// Arrange
|
||||
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
|
||||
const menuButton = within(container).getByRole('button')
|
||||
|
||||
// Act (Open)
|
||||
fireEvent.click(menuButton)
|
||||
// Assert
|
||||
expect(menuButton).toHaveClass('bg-state-base-hover')
|
||||
|
||||
// Act (Close - click again)
|
||||
fireEvent.click(menuButton)
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(menuButton).not.toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiLoopLeftLine,
|
||||
RiMoreFill,
|
||||
RiStickyNoteAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
|
||||
import { useInvalidDataSourceIntegrates } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type OperateProps = {
|
||||
payload: {
|
||||
id: string
|
||||
total: number
|
||||
}
|
||||
onAuthAgain: () => void
|
||||
}
|
||||
export default function Operate({
|
||||
payload,
|
||||
onAuthAgain,
|
||||
}: OperateProps) {
|
||||
const { t } = useTranslation()
|
||||
const invalidateDataSourceIntegrates = useInvalidDataSourceIntegrates()
|
||||
|
||||
const updateIntegrates = () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
invalidateDataSourceIntegrates()
|
||||
}
|
||||
const handleSync = async () => {
|
||||
await syncDataSourceNotion({ url: `/oauth/data-source/notion/${payload.id}/sync` })
|
||||
updateIntegrates()
|
||||
}
|
||||
const handleRemove = async () => {
|
||||
await updateDataSourceNotionAction({ url: `/data-source/integrates/${payload.id}/disable` })
|
||||
updateIntegrates()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn('flex h-8 w-8 items-center justify-center rounded-lg hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className="h-4 w-4 text-text-secondary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 top-9 w-60 max-w-80 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
<div
|
||||
className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover"
|
||||
onClick={onAuthAgain}
|
||||
>
|
||||
<RiStickyNoteAddLine className="mr-2 mt-[2px] h-4 w-4 text-text-tertiary" />
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{payload.total}
|
||||
{' '}
|
||||
{t('dataSource.notion.pagesAuthorized', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleSync}>
|
||||
<RiLoopLeftLine className="mr-2 mt-[2px] h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('dataSource.notion.sync', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleRemove}>
|
||||
<RiDeleteBinLine className="mr-2 mt-[2px] h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('dataSource.notion.remove', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import ConfigFirecrawlModal from '../config-firecrawl-modal'
|
||||
|
||||
/**
|
||||
* ConfigFirecrawlModal Component Tests
|
||||
* Tests validation, save logic, and basic rendering for the Firecrawl configuration modal.
|
||||
*/
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createDataSourceApiKeyBinding: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ConfigFirecrawlModal Component', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSaved = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial Rendering', () => {
|
||||
it('should render the modal with all fields and buttons', () => {
|
||||
// Act
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('https://api.firecrawl.dev')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /datasetCreation\.firecrawl\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://www.firecrawl.dev/account')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update state when input fields change', async () => {
|
||||
// Arrange
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
|
||||
const baseUrlInput = screen.getByPlaceholderText('https://api.firecrawl.dev')
|
||||
|
||||
// Act
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'firecrawl-key' } })
|
||||
fireEvent.change(baseUrlInput, { target: { value: 'https://custom.firecrawl.dev' } })
|
||||
|
||||
// Assert
|
||||
expect(apiKeyInput).toHaveValue('firecrawl-key')
|
||||
expect(baseUrlInput).toHaveValue('https://custom.firecrawl.dev')
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should show error when saving without API Key', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
|
||||
})
|
||||
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error for invalid Base URL format', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
const baseUrlInput = screen.getByPlaceholderText('https://api.firecrawl.dev')
|
||||
|
||||
// Act
|
||||
await user.type(baseUrlInput, 'ftp://invalid-url.com')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.errorMsg.urlError')).toBeInTheDocument()
|
||||
})
|
||||
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving Logic', () => {
|
||||
it('should save successfully with valid API Key and custom URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'valid-key')
|
||||
await user.type(screen.getByPlaceholderText('https://api.firecrawl.dev'), 'http://my-firecrawl.com')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
|
||||
category: 'website',
|
||||
provider: 'firecrawl',
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: 'valid-key',
|
||||
base_url: 'http://my-firecrawl.com',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.api.success')).toBeInTheDocument()
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default Base URL if none is provided during save', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
|
||||
credentials: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
base_url: 'https://api.firecrawl.dev',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore multiple save clicks while saving is in progress', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
let resolveSave: (value: CommonResponse) => void
|
||||
const savePromise = new Promise<CommonResponse>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
|
||||
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
|
||||
// Act
|
||||
await user.click(saveBtn)
|
||||
await user.click(saveBtn)
|
||||
|
||||
// Assert
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveSave!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should accept base_url starting with https://', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
|
||||
await user.type(screen.getByPlaceholderText('https://api.firecrawl.dev'), 'https://secure-firecrawl.com')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
|
||||
credentials: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
base_url: 'https://secure-firecrawl.com',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import ConfigJinaReaderModal from '../config-jina-reader-modal'
|
||||
|
||||
/**
|
||||
* ConfigJinaReaderModal Component Tests
|
||||
* Tests validation, save logic, and basic rendering for the Jina Reader configuration modal.
|
||||
*/
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createDataSourceApiKeyBinding: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ConfigJinaReaderModal Component', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSaved = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial Rendering', () => {
|
||||
it('should render the modal with API Key field and buttons', () => {
|
||||
// Act
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://jina.ai/reader/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update state when API Key field changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
|
||||
|
||||
// Act
|
||||
await user.type(apiKeyInput, 'jina-test-key')
|
||||
|
||||
// Assert
|
||||
expect(apiKeyInput).toHaveValue('jina-test-key')
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should show error when saving without API Key', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
|
||||
})
|
||||
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving Logic', () => {
|
||||
it('should save successfully with valid API Key', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
|
||||
|
||||
// Act
|
||||
await user.type(apiKeyInput, 'valid-jina-key')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
|
||||
category: 'website',
|
||||
provider: DataSourceProvider.jinaReader,
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: 'valid-jina-key',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.api.success')).toBeInTheDocument()
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore multiple save clicks while saving is in progress', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
let resolveSave: (value: { result: 'success' }) => void
|
||||
const savePromise = new Promise<{ result: 'success' }>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder'), 'test-key')
|
||||
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
|
||||
// Act
|
||||
await user.click(saveBtn)
|
||||
await user.click(saveBtn)
|
||||
|
||||
// Assert
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveSave!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should show encryption info and external link in the modal', async () => {
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Verify PKCS1_OAEP link exists
|
||||
const pkcsLink = screen.getByText('PKCS1_OAEP')
|
||||
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
|
||||
|
||||
// Verify the Jina Reader external link
|
||||
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
|
||||
expect(jinaLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange - a save that never resolves so isSaving stays true
|
||||
let resolveFirst: (value: { result: 'success' }) => void
|
||||
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
|
||||
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
|
||||
await user.type(apiKeyInput, 'valid-key')
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
// First click - starts saving, isSaving becomes true
|
||||
await user.click(saveBtn)
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second click using fireEvent bypasses disabled check - hits isSaving guard
|
||||
const { fireEvent: fe } = await import('@testing-library/react')
|
||||
fe.click(saveBtn)
|
||||
// Still only called once because isSaving=true returns early
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveFirst!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import ConfigWatercrawlModal from '../config-watercrawl-modal'
|
||||
|
||||
/**
|
||||
* ConfigWatercrawlModal Component Tests
|
||||
* Tests validation, save logic, and basic rendering for the Watercrawl configuration modal.
|
||||
*/
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createDataSourceApiKeyBinding: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ConfigWatercrawlModal Component', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSaved = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial Rendering', () => {
|
||||
it('should render the modal with all fields and buttons', () => {
|
||||
// Act
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('https://app.watercrawl.dev')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /datasetCreation\.watercrawl\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://app.watercrawl.dev/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update state when input fields change', async () => {
|
||||
// Arrange
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder')
|
||||
const baseUrlInput = screen.getByPlaceholderText('https://app.watercrawl.dev')
|
||||
|
||||
// Act
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'water-key' } })
|
||||
fireEvent.change(baseUrlInput, { target: { value: 'https://custom.watercrawl.dev' } })
|
||||
|
||||
// Assert
|
||||
expect(apiKeyInput).toHaveValue('water-key')
|
||||
expect(baseUrlInput).toHaveValue('https://custom.watercrawl.dev')
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should show error when saving without API Key', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
|
||||
})
|
||||
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error for invalid Base URL format', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
const baseUrlInput = screen.getByPlaceholderText('https://app.watercrawl.dev')
|
||||
|
||||
// Act
|
||||
await user.type(baseUrlInput, 'ftp://invalid-url.com')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.errorMsg.urlError')).toBeInTheDocument()
|
||||
})
|
||||
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving Logic', () => {
|
||||
it('should save successfully with valid API Key and custom URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'valid-key')
|
||||
await user.type(screen.getByPlaceholderText('https://app.watercrawl.dev'), 'http://my-watercrawl.com')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
|
||||
category: 'website',
|
||||
provider: 'watercrawl',
|
||||
credentials: {
|
||||
auth_type: 'x-api-key',
|
||||
config: {
|
||||
api_key: 'valid-key',
|
||||
base_url: 'http://my-watercrawl.com',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.api.success')).toBeInTheDocument()
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default Base URL if none is provided during save', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
|
||||
credentials: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
base_url: 'https://app.watercrawl.dev',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore multiple save clicks while saving is in progress', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
let resolveSave: (value: CommonResponse) => void
|
||||
const savePromise = new Promise<CommonResponse>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
|
||||
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
|
||||
// Act
|
||||
await user.click(saveBtn)
|
||||
await user.click(saveBtn)
|
||||
|
||||
// Assert
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Cleanup
|
||||
resolveSave!({ result: 'success' })
|
||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should accept base_url starting with https://', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
|
||||
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||
|
||||
// Act
|
||||
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
|
||||
await user.type(screen.getByPlaceholderText('https://app.watercrawl.dev'), 'https://secure-watercrawl.com')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
|
||||
credentials: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
base_url: 'https://secure-watercrawl.com',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import DataSourceWebsite from '../index'
|
||||
|
||||
/**
|
||||
* DataSourceWebsite Component Tests
|
||||
* Tests integration of multiple website scraping providers (Firecrawl, WaterCrawl, Jina Reader).
|
||||
*/
|
||||
|
||||
type DataSourcesResponse = CommonResponse & {
|
||||
sources: Array<{ id: string, provider: DataSourceProvider }>
|
||||
}
|
||||
|
||||
// Mock App Context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock Service calls
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
fetchDataSources: vi.fn(),
|
||||
removeDataSourceApiKeyBinding: vi.fn(),
|
||||
createDataSourceApiKeyBinding: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('DataSourceWebsite Component', () => {
|
||||
const mockSources = [
|
||||
{ id: '1', provider: DataSourceProvider.fireCrawl },
|
||||
{ id: '2', provider: DataSourceProvider.waterCrawl },
|
||||
{ id: '3', provider: DataSourceProvider.jinaReader },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: true } as unknown as AppContextValue)
|
||||
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [] } as DataSourcesResponse)
|
||||
})
|
||||
|
||||
// Helper to render and wait for initial fetch to complete
|
||||
const renderAndWait = async (provider: DataSourceProvider) => {
|
||||
const result = render(<DataSourceWebsite provider={provider} />)
|
||||
await waitFor(() => expect(fetchDataSources).toHaveBeenCalled())
|
||||
return result
|
||||
}
|
||||
|
||||
describe('Data Initialization', () => {
|
||||
it('should fetch data sources on mount and reflect configured status', async () => {
|
||||
// Arrange
|
||||
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: mockSources } as DataSourcesResponse)
|
||||
|
||||
// Act
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass readOnly status based on workspace manager permissions', async () => {
|
||||
// Arrange
|
||||
vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: false } as unknown as AppContextValue)
|
||||
|
||||
// Act
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.configure')).toHaveClass('cursor-default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Specific Rendering', () => {
|
||||
it('should render correct logo and name for Firecrawl', async () => {
|
||||
// Arrange
|
||||
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[0]] } as DataSourcesResponse)
|
||||
|
||||
// Act
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('Firecrawl')).toBeInTheDocument()
|
||||
expect(screen.getByText('🔥')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correct logo and name for WaterCrawl', async () => {
|
||||
// Arrange
|
||||
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[1]] } as DataSourcesResponse)
|
||||
|
||||
// Act
|
||||
await renderAndWait(DataSourceProvider.waterCrawl)
|
||||
|
||||
// Assert
|
||||
const elements = await screen.findAllByText('WaterCrawl')
|
||||
expect(elements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render correct logo and name for Jina Reader', async () => {
|
||||
// Arrange
|
||||
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[2]] } as DataSourcesResponse)
|
||||
|
||||
// Act
|
||||
await renderAndWait(DataSourceProvider.jinaReader)
|
||||
|
||||
// Assert
|
||||
const elements = await screen.findAllByText('Jina Reader')
|
||||
expect(elements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Interactions', () => {
|
||||
it('should manage opening and closing of configuration modals', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
|
||||
// Act (Open)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
// Assert
|
||||
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
|
||||
|
||||
// Act (Cancel)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
// Assert
|
||||
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-fetch sources after saving configuration (Watercrawl)', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.waterCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
vi.mocked(fetchDataSources).mockClear()
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), { target: { value: 'test-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchDataSources).toHaveBeenCalled()
|
||||
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should re-fetch sources after saving configuration (Jina Reader)', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.jinaReader)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
vi.mocked(fetchDataSources).mockClear()
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder'), { target: { value: 'test-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchDataSources).toHaveBeenCalled()
|
||||
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Management Actions', () => {
|
||||
it('should handle successful data source removal with toast notification', async () => {
|
||||
// Arrange
|
||||
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[0]] } as DataSourcesResponse)
|
||||
vi.mocked(removeDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' } as CommonResponse)
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
await waitFor(() => expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument())
|
||||
|
||||
// Act
|
||||
const removeBtn = screen.getByText('Firecrawl').parentElement?.querySelector('svg')?.parentElement
|
||||
if (removeBtn)
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(removeDataSourceApiKeyBinding).toHaveBeenCalledWith('1')
|
||||
expect(screen.getByText('common.api.remove')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('common.dataSource.website.configuredCrawlers')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should skip removal API call if no data source ID is present', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
|
||||
// Act
|
||||
const removeBtn = screen.queryByText('Firecrawl')?.parentElement?.querySelector('svg')?.parentElement
|
||||
if (removeBtn)
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
// Assert
|
||||
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Firecrawl Save Flow', () => {
|
||||
it('should re-fetch sources after saving Firecrawl configuration', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
|
||||
vi.mocked(fetchDataSources).mockClear()
|
||||
|
||||
// Act - fill in required API key field and save
|
||||
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchDataSources).toHaveBeenCalled()
|
||||
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Flow', () => {
|
||||
it('should close watercrawl modal when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.waterCrawl)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert - modal closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close jina reader modal when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
await renderAndWait(DataSourceProvider.jinaReader)
|
||||
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
// Assert - modal closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FirecrawlConfig } from '@/models/common'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'firecrawl'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.firecrawl.dev'
|
||||
|
||||
const ConfigFirecrawlModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<FirecrawlConfig>({
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
})
|
||||
|
||||
const handleConfigChange = useCallback((key: string) => {
|
||||
return (value: string | number) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value as string }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (config.base_url && !((config.base_url.startsWith('http://') || config.base_url.startsWith('https://'))))
|
||||
errorMsg = t('errorMsg.urlError', { ns: 'common' })
|
||||
if (!errorMsg) {
|
||||
if (!config.api_key) {
|
||||
errorMsg = t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: 'firecrawl',
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: config.api_key,
|
||||
base_url: config.base_url || DEFAULT_BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [config.api_key, config.base_url, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className="z-[60] h-full w-full">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
|
||||
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
|
||||
<div className="px-8 pt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`${I18N_PREFIX}.configFirecrawl`, { ns: 'datasetCreation' })}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="API Key"
|
||||
labelClassName="!text-sm"
|
||||
isRequired
|
||||
value={config.api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`, { ns: 'datasetCreation' })!}
|
||||
/>
|
||||
<Field
|
||||
label="Base URL"
|
||||
labelClassName="!text-sm"
|
||||
value={config.base_url}
|
||||
onChange={handleConfigChange('base_url')}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8 flex h-8 items-center justify-between">
|
||||
<a className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent" target="_blank" href="https://www.firecrawl.dev/account">
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`, { ns: 'datasetCreation' })}</span>
|
||||
<LinkExternal02 className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="large"
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
<a
|
||||
className="mx-1 text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigFirecrawlModal)
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'jinaReader'
|
||||
|
||||
const ConfigJinaReaderModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (!errorMsg) {
|
||||
if (!apiKey) {
|
||||
errorMsg = t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: DataSourceProvider.jinaReader,
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: apiKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [apiKey, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className="z-[60] h-full w-full">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
|
||||
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
|
||||
<div className="px-8 pt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`${I18N_PREFIX}.configJinaReader`, { ns: 'datasetCreation' })}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="API Key"
|
||||
labelClassName="!text-sm"
|
||||
isRequired
|
||||
value={apiKey}
|
||||
onChange={(value: string | number) => setApiKey(value as string)}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`, { ns: 'datasetCreation' })!}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8 flex h-8 items-center justify-between">
|
||||
<a className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent" target="_blank" href="https://jina.ai/reader/">
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`, { ns: 'datasetCreation' })}</span>
|
||||
<LinkExternal02 className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="large"
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
<a
|
||||
className="mx-1 text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigJinaReaderModal)
|
||||
@@ -1,165 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { WatercrawlConfig } from '@/models/common'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'watercrawl'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://app.watercrawl.dev'
|
||||
|
||||
const ConfigWatercrawlModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<WatercrawlConfig>({
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
})
|
||||
|
||||
const handleConfigChange = useCallback((key: string) => {
|
||||
return (value: string | number) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value as string }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (config.base_url && !((config.base_url.startsWith('http://') || config.base_url.startsWith('https://'))))
|
||||
errorMsg = t('errorMsg.urlError', { ns: 'common' })
|
||||
if (!errorMsg) {
|
||||
if (!config.api_key) {
|
||||
errorMsg = t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: 'watercrawl',
|
||||
credentials: {
|
||||
auth_type: 'x-api-key',
|
||||
config: {
|
||||
api_key: config.api_key,
|
||||
base_url: config.base_url || DEFAULT_BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.success', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [config.api_key, config.base_url, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className="z-[60] h-full w-full">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
|
||||
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
|
||||
<div className="px-8 pt-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`${I18N_PREFIX}.configWatercrawl`, { ns: 'datasetCreation' })}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="API Key"
|
||||
labelClassName="!text-sm"
|
||||
isRequired
|
||||
value={config.api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`, { ns: 'datasetCreation' })!}
|
||||
/>
|
||||
<Field
|
||||
label="Base URL"
|
||||
labelClassName="!text-sm"
|
||||
value={config.base_url}
|
||||
onChange={handleConfigChange('base_url')}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-8 flex h-8 items-center justify-between">
|
||||
<a className="flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent" target="_blank" href="https://app.watercrawl.dev/">
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`, { ns: 'datasetCreation' })}</span>
|
||||
<LinkExternal02 className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="large"
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-[0.5px] border-t-divider-regular">
|
||||
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
|
||||
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
|
||||
{t('modelProvider.encrypted.front', { ns: 'common' })}
|
||||
<a
|
||||
className="mx-1 text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('modelProvider.encrypted.back', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigWatercrawlModal)
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceItem } from '@/models/common'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import s from '@/app/components/datasets/create/website/index.module.css'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Panel from '../panel'
|
||||
|
||||
import { DataSourceType } from '../panel/types'
|
||||
import ConfigFirecrawlModal from './config-firecrawl-modal'
|
||||
import ConfigJinaReaderModal from './config-jina-reader-modal'
|
||||
import ConfigWatercrawlModal from './config-watercrawl-modal'
|
||||
|
||||
type Props = {
|
||||
provider: DataSourceProvider
|
||||
}
|
||||
|
||||
const DataSourceWebsite: FC<Props> = ({ provider }) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [sources, setSources] = useState<DataSourceItem[]>([])
|
||||
const checkSetApiKey = useCallback(async () => {
|
||||
const res = await fetchDataSources() as any
|
||||
const list = res.sources
|
||||
setSources(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkSetApiKey()
|
||||
}, [])
|
||||
|
||||
const [configTarget, setConfigTarget] = useState<DataSourceProvider | null>(null)
|
||||
const showConfig = useCallback((provider: DataSourceProvider) => {
|
||||
setConfigTarget(provider)
|
||||
}, [setConfigTarget])
|
||||
|
||||
const hideConfig = useCallback(() => {
|
||||
setConfigTarget(null)
|
||||
}, [setConfigTarget])
|
||||
|
||||
const handleAdded = useCallback(() => {
|
||||
checkSetApiKey()
|
||||
hideConfig()
|
||||
}, [checkSetApiKey, hideConfig])
|
||||
|
||||
const getIdByProvider = (provider: DataSourceProvider): string | undefined => {
|
||||
const source = sources.find(item => item.provider === provider)
|
||||
return source?.id
|
||||
}
|
||||
|
||||
const getProviderName = (provider: DataSourceProvider): string => {
|
||||
if (provider === DataSourceProvider.fireCrawl)
|
||||
return 'Firecrawl'
|
||||
|
||||
if (provider === DataSourceProvider.waterCrawl)
|
||||
return 'WaterCrawl'
|
||||
|
||||
return 'Jina Reader'
|
||||
}
|
||||
|
||||
const handleRemove = useCallback((provider: DataSourceProvider) => {
|
||||
return async () => {
|
||||
const dataSourceId = getIdByProvider(provider)
|
||||
if (dataSourceId) {
|
||||
await removeDataSourceApiKeyBinding(dataSourceId)
|
||||
setSources(sources.filter(item => item.provider !== provider))
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.remove', { ns: 'common' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [sources, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
provider={provider}
|
||||
isConfigured={sources.find(item => item.provider === provider) !== undefined}
|
||||
onConfigure={() => showConfig(provider)}
|
||||
readOnly={!isCurrentWorkspaceManager}
|
||||
configuredList={sources.filter(item => item.provider === provider).map(item => ({
|
||||
id: item.id,
|
||||
logo: ({ className }: { className: string }) => {
|
||||
if (item.provider === DataSourceProvider.fireCrawl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}
|
||||
>
|
||||
🔥
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.provider === DataSourceProvider.waterCrawl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}
|
||||
>
|
||||
<span className={s.watercrawlLogo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}
|
||||
>
|
||||
<span className={s.jinaLogo} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
name: getProviderName(item.provider),
|
||||
isActive: true,
|
||||
}))}
|
||||
onRemove={handleRemove(provider)}
|
||||
/>
|
||||
{configTarget === DataSourceProvider.fireCrawl && (
|
||||
<ConfigFirecrawlModal onSaved={handleAdded} onCancel={hideConfig} />
|
||||
)}
|
||||
{configTarget === DataSourceProvider.waterCrawl && (
|
||||
<ConfigWatercrawlModal onSaved={handleAdded} onCancel={hideConfig} />
|
||||
)}
|
||||
{configTarget === DataSourceProvider.jinaReader && (
|
||||
<ConfigJinaReaderModal onSaved={handleAdded} onCancel={hideConfig} />
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(DataSourceWebsite)
|
||||
@@ -1,213 +0,0 @@
|
||||
import type { ConfigItemType } from '../config-item'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ConfigItem from '../config-item'
|
||||
import { DataSourceType } from '../types'
|
||||
|
||||
/**
|
||||
* ConfigItem Component Tests
|
||||
* Tests rendering of individual configuration items for Notion and Website data sources.
|
||||
*/
|
||||
|
||||
// Mock Operate component to isolate ConfigItem unit tests.
|
||||
vi.mock('../../data-source-notion/operate', () => ({
|
||||
default: ({ onAuthAgain, payload }: { onAuthAgain: () => void, payload: { id: string, total: number } }) => (
|
||||
<div data-testid="mock-operate">
|
||||
<button onClick={onAuthAgain} data-testid="operate-auth-btn">Auth Again</button>
|
||||
<span data-testid="operate-payload">{JSON.stringify(payload)}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ConfigItem Component', () => {
|
||||
const mockOnRemove = vi.fn()
|
||||
const mockOnChangeAuthorizedPage = vi.fn()
|
||||
const MockLogo = (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="mock-logo" {...props} />
|
||||
|
||||
const baseNotionPayload: ConfigItemType = {
|
||||
id: 'notion-1',
|
||||
logo: MockLogo,
|
||||
name: 'Notion Workspace',
|
||||
isActive: true,
|
||||
notionConfig: { total: 5 },
|
||||
}
|
||||
|
||||
const baseWebsitePayload: ConfigItemType = {
|
||||
id: 'website-1',
|
||||
logo: MockLogo,
|
||||
name: 'My Website',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Notion Configuration', () => {
|
||||
it('should render active Notion config item with connected status and operator', () => {
|
||||
// Act
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.notion}
|
||||
payload={baseNotionPayload}
|
||||
onRemove={mockOnRemove}
|
||||
notionActions={{ onChangeAuthorizedPage: mockOnChangeAuthorizedPage }}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('mock-logo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Notion Workspace')).toBeInTheDocument()
|
||||
const statusText = screen.getByText('common.dataSource.notion.connected')
|
||||
expect(statusText).toHaveClass('text-util-colors-green-green-600')
|
||||
expect(screen.getByTestId('operate-payload')).toHaveTextContent(JSON.stringify({ id: 'notion-1', total: 5 }))
|
||||
})
|
||||
|
||||
it('should render inactive Notion config item with disconnected status', () => {
|
||||
// Arrange
|
||||
const inactivePayload = { ...baseNotionPayload, isActive: false }
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.notion}
|
||||
payload={inactivePayload}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('common.dataSource.notion.disconnected')
|
||||
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
|
||||
})
|
||||
|
||||
it('should handle auth action through the Operate component', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.notion}
|
||||
payload={baseNotionPayload}
|
||||
onRemove={mockOnRemove}
|
||||
notionActions={{ onChangeAuthorizedPage: mockOnChangeAuthorizedPage }}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('operate-auth-btn'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnChangeAuthorizedPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fallback to 0 total if notionConfig is missing', () => {
|
||||
// Arrange
|
||||
const payloadNoConfig = { ...baseNotionPayload, notionConfig: undefined }
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.notion}
|
||||
payload={payloadNoConfig}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('operate-payload')).toHaveTextContent(JSON.stringify({ id: 'notion-1', total: 0 }))
|
||||
})
|
||||
|
||||
it('should handle missing notionActions safely without crashing', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.notion}
|
||||
payload={baseNotionPayload}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
expect(() => fireEvent.click(screen.getByTestId('operate-auth-btn'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Website Configuration', () => {
|
||||
it('should render active Website config item and hide operator', () => {
|
||||
// Act
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.website}
|
||||
payload={baseWebsitePayload}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.website.active')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('mock-operate')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render inactive Website config item', () => {
|
||||
// Arrange
|
||||
const inactivePayload = { ...baseWebsitePayload, isActive: false }
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.website}
|
||||
payload={inactivePayload}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('common.dataSource.website.inactive')
|
||||
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
|
||||
})
|
||||
|
||||
it('should show remove button and trigger onRemove when clicked (not read-only)', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.website}
|
||||
payload={baseWebsitePayload}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Note: This selector is brittle but necessary since the delete button lacks
|
||||
// accessible attributes (data-testid, aria-label). Ideally, the component should
|
||||
// be updated to include proper accessibility attributes.
|
||||
const deleteBtn = container.querySelector('div[class*="cursor-pointer"]') as HTMLElement
|
||||
|
||||
// Act
|
||||
fireEvent.click(deleteBtn)
|
||||
|
||||
// Assert
|
||||
expect(mockOnRemove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hide remove button in read-only mode', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<ConfigItem
|
||||
type={DataSourceType.website}
|
||||
payload={baseWebsitePayload}
|
||||
onRemove={mockOnRemove}
|
||||
readOnly={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const deleteBtn = container.querySelector('div[class*="cursor-pointer"]')
|
||||
expect(deleteBtn).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { ConfigItemType } from '../config-item'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import Panel from '../index'
|
||||
import { DataSourceType } from '../types'
|
||||
|
||||
/**
|
||||
* Panel Component Tests
|
||||
* Tests layout, conditional rendering, and interactions for data source panels (Notion and Website).
|
||||
*/
|
||||
|
||||
vi.mock('../../data-source-notion/operate', () => ({
|
||||
default: () => <div data-testid="mock-operate" />,
|
||||
}))
|
||||
|
||||
describe('Panel Component', () => {
|
||||
const onConfigure = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const mockConfiguredList: ConfigItemType[] = [
|
||||
{ id: '1', name: 'Item 1', isActive: true, logo: () => null },
|
||||
{ id: '2', name: 'Item 2', isActive: false, logo: () => null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Notion Panel Rendering', () => {
|
||||
it('should render Notion panel when not configured and isSupportList is true', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
isSupportList={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.notion.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.dataSource.notion.description')).toBeInTheDocument()
|
||||
const connectBtn = screen.getByText('common.dataSource.connect')
|
||||
expect(connectBtn).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
fireEvent.click(connectBtn)
|
||||
// Assert
|
||||
expect(onConfigure).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render Notion panel in readOnly mode when not configured', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={true}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
isSupportList={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const connectBtn = screen.getByText('common.dataSource.connect')
|
||||
expect(connectBtn).toHaveClass('cursor-default opacity-50 grayscale')
|
||||
})
|
||||
|
||||
it('should render Notion panel when configured with list of items', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={true}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={mockConfiguredList}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: 'common.dataSource.configure' })).toBeInTheDocument()
|
||||
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide connect button for Notion if isSupportList is false', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
isSupportList={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.dataSource.connect')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable Notion configure button in readOnly mode (configured state)', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={true}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={true}
|
||||
configuredList={mockConfiguredList}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const btn = screen.getByRole('button', { name: 'common.dataSource.configure' })
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Website Panel Rendering', () => {
|
||||
it('should show correct provider names and handle configuration when not configured', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
provider={DataSourceProvider.fireCrawl}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert Firecrawl
|
||||
expect(screen.getByText('🔥 Firecrawl')).toBeInTheDocument()
|
||||
|
||||
// Rerender for WaterCrawl
|
||||
rerender(
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
provider={DataSourceProvider.waterCrawl}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('WaterCrawl')).toBeInTheDocument()
|
||||
|
||||
// Rerender for Jina Reader
|
||||
rerender(
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
provider={DataSourceProvider.jinaReader}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
const configBtn = screen.getByText('common.dataSource.configure')
|
||||
fireEvent.click(configBtn)
|
||||
// Assert
|
||||
expect(onConfigure).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle readOnly mode for Website configuration button', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
isConfigured={false}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={true}
|
||||
configuredList={[]}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const configBtn = screen.getByText('common.dataSource.configure')
|
||||
expect(configBtn).toHaveClass('cursor-default opacity-50 grayscale')
|
||||
|
||||
// Act
|
||||
fireEvent.click(configBtn)
|
||||
// Assert
|
||||
expect(onConfigure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render Website panel correctly when configured with crawlers', () => {
|
||||
// Act
|
||||
render(
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
isConfigured={true}
|
||||
onConfigure={onConfigure}
|
||||
readOnly={false}
|
||||
configuredList={mockConfiguredList}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Indicator from '../../../indicator'
|
||||
import Operate from '../data-source-notion/operate'
|
||||
import s from './style.module.css'
|
||||
import { DataSourceType } from './types'
|
||||
|
||||
export type ConfigItemType = {
|
||||
id: string
|
||||
logo: any
|
||||
name: string
|
||||
isActive: boolean
|
||||
notionConfig?: {
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type: DataSourceType
|
||||
payload: ConfigItemType
|
||||
onRemove: () => void
|
||||
notionActions?: {
|
||||
onChangeAuthorizedPage: () => void
|
||||
}
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
const ConfigItem: FC<Props> = ({
|
||||
type,
|
||||
payload,
|
||||
onRemove,
|
||||
notionActions,
|
||||
readOnly,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isNotion = type === DataSourceType.notion
|
||||
const isWebsite = type === DataSourceType.website
|
||||
const onChangeAuthorizedPage = notionActions?.onChangeAuthorizedPage || noop
|
||||
|
||||
return (
|
||||
<div className={cn(s['workspace-item'], 'mb-1 flex items-center rounded-lg bg-components-panel-on-panel-item-bg py-1 pr-1')} key={payload.id}>
|
||||
<payload.logo className="ml-3 mr-1.5" />
|
||||
<div className="system-sm-medium grow truncate py-[7px] text-text-secondary" title={payload.name}>{payload.name}</div>
|
||||
{
|
||||
payload.isActive
|
||||
? <Indicator className="mr-[6px] shrink-0" color="green" />
|
||||
: <Indicator className="mr-[6px] shrink-0" color="yellow" />
|
||||
}
|
||||
<div className={`system-xs-semibold-uppercase mr-3 shrink-0 ${payload.isActive ? 'text-util-colors-green-green-600' : 'text-util-colors-warning-warning-600'}`}>
|
||||
{
|
||||
payload.isActive
|
||||
? t(isNotion ? 'dataSource.notion.connected' : 'dataSource.website.active', { ns: 'common' })
|
||||
: t(isNotion ? 'dataSource.notion.disconnected' : 'dataSource.website.inactive', { ns: 'common' })
|
||||
}
|
||||
</div>
|
||||
<div className="mr-2 h-3 w-[1px] bg-divider-regular" />
|
||||
{isNotion && (
|
||||
<Operate
|
||||
payload={{
|
||||
id: payload.id,
|
||||
total: payload.notionConfig?.total || 0,
|
||||
}}
|
||||
onAuthAgain={onChangeAuthorizedPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
isWebsite && !readOnly && (
|
||||
<div className="cursor-pointer rounded-md p-2 text-text-tertiary hover:bg-state-base-hover" onClick={onRemove}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigItem)
|
||||
@@ -1,151 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ConfigItemType } from './config-item'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ConfigItem from './config-item'
|
||||
import s from './style.module.css'
|
||||
import { DataSourceType } from './types'
|
||||
|
||||
type Props = {
|
||||
type: DataSourceType
|
||||
provider?: DataSourceProvider
|
||||
isConfigured: boolean
|
||||
onConfigure: () => void
|
||||
readOnly: boolean
|
||||
isSupportList?: boolean
|
||||
configuredList: ConfigItemType[]
|
||||
onRemove: () => void
|
||||
notionActions?: {
|
||||
onChangeAuthorizedPage: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const Panel: FC<Props> = ({
|
||||
type,
|
||||
provider,
|
||||
isConfigured,
|
||||
onConfigure,
|
||||
readOnly,
|
||||
configuredList,
|
||||
isSupportList,
|
||||
onRemove,
|
||||
notionActions,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isNotion = type === DataSourceType.notion
|
||||
const isWebsite = type === DataSourceType.website
|
||||
|
||||
const getProviderName = (): string => {
|
||||
if (provider === DataSourceProvider.fireCrawl)
|
||||
return '🔥 Firecrawl'
|
||||
if (provider === DataSourceProvider.waterCrawl)
|
||||
return 'WaterCrawl'
|
||||
return 'Jina Reader'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-xl bg-background-section-burn">
|
||||
<div className="flex items-center px-3 py-[9px]">
|
||||
<div className={cn(s[`${type}-icon`], 'mr-3 h-8 w-8 rounded-lg border border-divider-subtle !bg-background-default')} />
|
||||
<div className="grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<div className="text-sm font-medium text-text-primary">{t(`dataSource.${type}.title`, { ns: 'common' })}</div>
|
||||
{isWebsite && (
|
||||
<div className="ml-1 rounded-md bg-components-badge-white-to-dark px-1.5 text-xs font-medium leading-[18px] text-text-secondary">
|
||||
<span className="text-text-tertiary">{t('dataSource.website.with', { ns: 'common' })}</span>
|
||||
{' '}
|
||||
{getProviderName()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
!isConfigured && (
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
{t(`dataSource.${type}.description`, { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{isNotion && (
|
||||
<>
|
||||
{
|
||||
isConfigured
|
||||
? (
|
||||
<Button
|
||||
disabled={readOnly}
|
||||
className="ml-3"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{isSupportList && (
|
||||
<div
|
||||
className={
|
||||
`system-sm-medium flex min-h-7 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-1 text-components-button-secondary-accent-text
|
||||
${!readOnly ? 'cursor-pointer' : 'cursor-default opacity-50 grayscale'}`
|
||||
}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<RiAddLine className="mr-[5px] h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
{t('dataSource.connect', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWebsite && !isConfigured && (
|
||||
<div
|
||||
className={
|
||||
`ml-3 flex h-7 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg
|
||||
px-3 text-xs font-medium text-components-button-secondary-accent-text
|
||||
${!readOnly ? 'cursor-pointer' : 'cursor-default opacity-50 grayscale'}`
|
||||
}
|
||||
onClick={!readOnly ? onConfigure : undefined}
|
||||
>
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{
|
||||
isConfigured && (
|
||||
<>
|
||||
<div className="flex h-[18px] items-center px-3">
|
||||
<div className="system-xs-medium text-text-tertiary">
|
||||
{isNotion ? t('dataSource.notion.connectedWorkspace', { ns: 'common' }) : t('dataSource.website.configuredCrawlers', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="ml-3 grow border-t border-t-divider-subtle" />
|
||||
</div>
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
{
|
||||
configuredList.map(item => (
|
||||
<ConfigItem
|
||||
key={item.id}
|
||||
type={type}
|
||||
payload={item}
|
||||
onRemove={onRemove}
|
||||
notionActions={notionActions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Panel)
|
||||
@@ -1,17 +0,0 @@
|
||||
.notion-icon {
|
||||
background: #ffffff url(../../../assets/notion.svg) center center no-repeat;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.website-icon {
|
||||
background: #ffffff url(../../../../datasets/create/assets/web.svg) center center no-repeat;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.workspace-item {
|
||||
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.workspace-item:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum DataSourceType {
|
||||
notion = 'notion',
|
||||
website = 'website',
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import type { InvitationResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import InviteModal from '../index'
|
||||
|
||||
const { mockToastError } = vi.hoisted(() => ({
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
useProviderContext: vi.fn(() => ({
|
||||
@@ -14,6 +18,11 @@ vi.mock('@/context/provider-context', () => ({
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/service/common')
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
@@ -37,7 +46,6 @@ describe('InviteModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSend = vi.fn()
|
||||
const mockRefreshLicenseLimit = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -49,10 +57,11 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
const renderModal = (isEmailSetup = true) => render(
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
|
||||
</ToastContext.Provider>,
|
||||
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />,
|
||||
)
|
||||
const fillEmails = (value: string) => {
|
||||
fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } })
|
||||
}
|
||||
|
||||
it('should render invite modal content', async () => {
|
||||
renderModal()
|
||||
@@ -68,12 +77,8 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should enable send button after entering an email', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
|
||||
})
|
||||
@@ -84,7 +89,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -103,8 +108,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -116,8 +120,6 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should keep send button disabled when license limit is exceeded', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
@@ -125,8 +127,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
|
||||
})
|
||||
@@ -144,15 +145,11 @@ describe('InviteModal', () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
// Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD)
|
||||
await user.type(input, 'invalid@email.c')
|
||||
fillEmails('invalid@email.c')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.members.emailInvalid',
|
||||
})
|
||||
expect(toast.error).toHaveBeenCalledWith('common.members.emailInvalid')
|
||||
expect(inviteMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -160,8 +157,7 @@ describe('InviteModal', () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument()
|
||||
|
||||
@@ -203,7 +199,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -214,8 +210,6 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should show destructive text color when used size exceeds limit', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
@@ -223,8 +217,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
|
||||
const counter = screen.getByText('11')
|
||||
@@ -241,8 +234,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
|
||||
@@ -264,8 +256,6 @@ describe('InviteModal', () => {
|
||||
})
|
||||
|
||||
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
|
||||
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||
@@ -274,8 +264,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
// isLimitExceeded=true → button is disabled, cannot submit
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
@@ -293,8 +282,7 @@ describe('InviteModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||
|
||||
@@ -320,11 +308,9 @@ describe('InviteModal', () => {
|
||||
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||
} as unknown as Parameters<typeof selector>[0]))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByTestId('mock-email-input')
|
||||
await user.type(input, 'user@example.com')
|
||||
fillEmails('user@example.com')
|
||||
|
||||
// isLimited=false → no destructive color
|
||||
const counter = screen.getByText('1')
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
.modal {
|
||||
padding: 24px 32px !important;
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.emailsInput {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.emailBackground {
|
||||
background-color: white !important;
|
||||
}
|
||||
@@ -2,20 +2,17 @@
|
||||
import type { RoleKey } from './role-selector'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactMultiEmail } from 'react-multi-email'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { emailRegex } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './index.module.css'
|
||||
import RoleSelector from './role-selector'
|
||||
import 'react-multi-email/dist/style.css'
|
||||
|
||||
@@ -34,7 +31,6 @@ const InviteModal = ({
|
||||
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
|
||||
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
|
||||
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
|
||||
@@ -74,21 +70,28 @@ const InviteModal = ({
|
||||
catch { }
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('members.emailInvalid', { ns: 'common' }) })
|
||||
toast.error(t('members.emailInvalid', { ns: 'common' }))
|
||||
}
|
||||
setIsSubmitted()
|
||||
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
|
||||
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrap)}>
|
||||
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="text-xl font-semibold text-text-primary">{t('members.inviteTeamMember', { ns: 'common' })}</div>
|
||||
<div
|
||||
data-testid="invite-modal-close"
|
||||
className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[400px] overflow-visible px-8 py-6"
|
||||
>
|
||||
<DialogCloseButton data-testid="invite-modal-close" className="right-8 top-6" />
|
||||
<div className="mb-2 pr-8">
|
||||
<DialogTitle className="text-xl font-semibold text-text-primary">
|
||||
{t('members.inviteTeamMember', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="mb-3 text-[13px] text-text-tertiary">{t('members.inviteTeamMemberTip', { ns: 'common' })}</div>
|
||||
{!isEmailSetup && (
|
||||
@@ -152,8 +155,8 @@ const InviteModal = ({
|
||||
{t('members.sendInvite', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -25,115 +24,111 @@ export type RoleSelectorProps = {
|
||||
|
||||
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<PopoverTrigger
|
||||
data-testid="role-selector-trigger"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
|
||||
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
|
||||
>
|
||||
<div className="p-1">
|
||||
<div
|
||||
data-testid="role-selector-trigger"
|
||||
className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}
|
||||
data-testid="role-option-normal"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('normal')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
|
||||
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className="relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div
|
||||
data-testid="role-option-normal"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('normal')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
|
||||
{value === 'normal' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-editor"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('editor')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
|
||||
{value === 'editor' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-admin"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('admin')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
|
||||
{value === 'admin' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{datasetOperatorEnabled && (
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
|
||||
{value === 'normal' && (
|
||||
<div
|
||||
data-testid="role-option-dataset_operator"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('dataset_operator')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
|
||||
{value === 'dataset_operator' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
<div
|
||||
data-testid="role-option-editor"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('editor')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
|
||||
{value === 'editor' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="role-option-admin"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('admin')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
|
||||
{value === 'admin' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{datasetOperatorEnabled && (
|
||||
<div
|
||||
data-testid="role-option-dataset_operator"
|
||||
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange('dataset_operator')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative pl-5">
|
||||
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
|
||||
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
|
||||
{value === 'dataset_operator' && (
|
||||
<div
|
||||
data-testid="role-option-check"
|
||||
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import s from './index.module.css'
|
||||
import InvitationLink from './invitation-link'
|
||||
|
||||
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
|
||||
@@ -29,8 +24,18 @@ const InvitedModal = ({
|
||||
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
|
||||
|
||||
return (
|
||||
<div className={s.wrap}>
|
||||
<Modal isShow onClose={noop} className={s.modal}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[480px] p-8"
|
||||
>
|
||||
<DialogCloseButton className="right-8 top-8" />
|
||||
<div className="mb-3 flex justify-between">
|
||||
<div className="
|
||||
flex h-12 w-12 items-center justify-center rounded-xl
|
||||
@@ -38,11 +43,10 @@ const InvitedModal = ({
|
||||
shadow-xl
|
||||
"
|
||||
>
|
||||
<CheckCircleIcon className="h-[22px] w-[22px] text-[#039855]" />
|
||||
<div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" />
|
||||
</div>
|
||||
<XMarkIcon className="h-4 w-4 cursor-pointer" onClick={onCancel} />
|
||||
</div>
|
||||
<div className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</div>
|
||||
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</DialogTitle>
|
||||
{!IS_CE_EDITION && (
|
||||
<div className="mb-10 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
|
||||
)}
|
||||
@@ -54,7 +58,7 @@ const InvitedModal = ({
|
||||
!!successInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
|
||||
{successInvitationResults.map(item =>
|
||||
<InvitationLink key={item.email} value={item} />)}
|
||||
</>
|
||||
@@ -64,18 +68,23 @@ const InvitedModal = ({
|
||||
!!failedInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
|
||||
<div className="flex flex-wrap justify-between gap-y-1">
|
||||
{
|
||||
failedInvitationResults.map(item => (
|
||||
<div key={item.email} className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1">
|
||||
<Tooltip
|
||||
popupContent={item.message}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 text-sm">
|
||||
{item.email}
|
||||
<RiQuestionLine className="h-4 w-4 text-red-300" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex items-center justify-center gap-1 text-sm">
|
||||
{item.email}
|
||||
<div className="i-ri-question-line h-4 w-4 text-red-300" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{item.message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
@@ -97,8 +106,8 @@ const InvitedModal = ({
|
||||
{t('members.ok', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
|
||||
import { t } from 'i18next'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import s from './index.module.css'
|
||||
|
||||
type IInvitationLinkProps = {
|
||||
@@ -38,20 +38,28 @@ const InvitationLink = ({
|
||||
<div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover" data-testid="invitation-link-container">
|
||||
<div className="flex h-5 grow items-center">
|
||||
<div className="relative h-full grow text-[13px]">
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
|
||||
>
|
||||
<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="h-4 shrink-0 border bg-divider-regular" />
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
|
||||
>
|
||||
<div className="shrink-0 px-0.5">
|
||||
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="shrink-0 px-0.5">
|
||||
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ const Operation = ({
|
||||
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[999]">
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
|
||||
<div className="p-1">
|
||||
{
|
||||
|
||||
@@ -141,6 +141,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="!w-[420px] !p-6"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -77,7 +77,7 @@ const MemberSelector: FC<Props> = ({
|
||||
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
|
||||
@@ -116,7 +116,7 @@ const AddCustomModel = ({
|
||||
>
|
||||
{renderTrigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="max-h-[304px] overflow-y-auto p-1">
|
||||
{
|
||||
@@ -136,7 +136,7 @@ const AddCustomModel = ({
|
||||
modelName={model.model}
|
||||
/>
|
||||
<div
|
||||
className="system-md-regular grow truncate text-text-primary"
|
||||
className="grow truncate text-text-primary system-md-regular"
|
||||
title={model.model}
|
||||
>
|
||||
{model.model}
|
||||
@@ -148,7 +148,7 @@ const AddCustomModel = ({
|
||||
{
|
||||
!notAllowCustomCredential && (
|
||||
<div
|
||||
className="system-xs-medium flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only"
|
||||
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only system-xs-medium"
|
||||
onClick={() => {
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
setOpen(false)
|
||||
|
||||
@@ -164,7 +164,7 @@ const Authorized = ({
|
||||
>
|
||||
{renderTrigger(mergedIsOpen)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className={cn(
|
||||
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
|
||||
popupClassName,
|
||||
@@ -172,7 +172,7 @@ const Authorized = ({
|
||||
>
|
||||
{
|
||||
popupTitle && (
|
||||
<div className="system-xs-medium px-3 pb-0.5 pt-[10px] text-text-tertiary">
|
||||
<div className="px-3 pb-0.5 pt-[10px] text-text-tertiary system-xs-medium">
|
||||
{popupTitle}
|
||||
</div>
|
||||
)
|
||||
@@ -218,7 +218,7 @@ const Authorized = ({
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
className="system-xs-medium flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only"
|
||||
className="flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only system-xs-medium"
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
{t('modelProvider.auth.addModelCredential', { ns: 'common' })}
|
||||
|
||||
@@ -53,14 +53,14 @@ const CredentialSelector = ({
|
||||
triggerPopupSameWidth
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => !disabled && setOpen(v => !v)}>
|
||||
<div className="system-sm-regular flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2">
|
||||
<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular">
|
||||
{
|
||||
selectedCredential && (
|
||||
<div className="flex items-center">
|
||||
{
|
||||
!selectedCredential.addNewCredential && <Indicator className="ml-1 mr-2 shrink-0" />
|
||||
}
|
||||
<div className="system-sm-regular truncate text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
|
||||
<div className="truncate text-components-input-text-filled system-sm-regular" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
|
||||
{
|
||||
selectedCredential.from_enterprise && (
|
||||
<Badge className="shrink-0">Enterprise</Badge>
|
||||
@@ -71,13 +71,13 @@ const CredentialSelector = ({
|
||||
}
|
||||
{
|
||||
!selectedCredential && (
|
||||
<div className="system-sm-regular grow truncate text-components-input-text-placeholder">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
|
||||
<div className="grow truncate text-components-input-text-placeholder system-sm-regular">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
<PortalToFollowElemContent className="z-[1002]">
|
||||
<div className="border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="max-h-[320px] overflow-y-auto p-1">
|
||||
{
|
||||
@@ -98,7 +98,7 @@ const CredentialSelector = ({
|
||||
{
|
||||
!notAllowAddNewCredential && (
|
||||
<div
|
||||
className="system-xs-medium flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only"
|
||||
className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only system-xs-medium"
|
||||
onClick={handleAddNewCredential}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
|
||||
@@ -244,6 +244,7 @@ const ModelLoadBalancingModal = ({
|
||||
<Modal
|
||||
isShow={Boolean(model) && open}
|
||||
onClose={onClose}
|
||||
wrapperClassName="z-[1002]"
|
||||
className="w-[640px] max-w-none px-8 pt-8"
|
||||
title={(
|
||||
<div className="pb-3 font-semibold">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { use } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -23,14 +24,14 @@ vi.mock('@headlessui/react', () => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const value = React.useMemo(() => ({ open, setOpen }), [open])
|
||||
return (
|
||||
<MenuContext.Provider value={value}>
|
||||
<MenuContext value={value}>
|
||||
{typeof children === 'function' ? children({ open }) : children}
|
||||
</MenuContext.Provider>
|
||||
</MenuContext>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
|
||||
const context = React.useContext(MenuContext)
|
||||
const context = use(MenuContext)
|
||||
const handleClick = () => {
|
||||
context?.setOpen(!context.open)
|
||||
onClick?.()
|
||||
@@ -43,7 +44,7 @@ vi.mock('@headlessui/react', () => {
|
||||
}
|
||||
|
||||
const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => {
|
||||
const context = React.useContext(MenuContext)
|
||||
const context = use(MenuContext)
|
||||
if (!context?.open)
|
||||
return null
|
||||
return (
|
||||
@@ -84,6 +85,26 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string, children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClick?.(event)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Nav Component', () => {
|
||||
const mockSetAppDetail = vi.fn()
|
||||
const mockOnCreate = vi.fn()
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Tabs from '../tabs'
|
||||
import { TabsEnum } from '../types'
|
||||
|
||||
const {
|
||||
mockSetState,
|
||||
mockInvalidateBuiltInTools,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetState: vi.fn(),
|
||||
mockInvalidateBuiltInTools: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
children,
|
||||
popupContent,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupContent: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<span>{popupContent}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedToolsRecommendations: () => ({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: [{ icon: '/tool.svg', name: 'tool' }] }),
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
useInvalidateAllBuiltInTools: () => mockInvalidateBuiltInTools,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '/console',
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
setState: mockSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../all-start-blocks', () => ({
|
||||
default: () => <div>start-content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../blocks', () => ({
|
||||
default: () => <div>blocks-content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../data-sources', () => ({
|
||||
default: () => <div>sources-content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../all-tools', () => ({
|
||||
default: (props: { buildInTools: Array<{ icon: string }> }) => (
|
||||
<div>
|
||||
tools-content
|
||||
<span>{props.buildInTools[0]?.icon}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Tabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
activeTab: TabsEnum.Start,
|
||||
onActiveTabChange: vi.fn(),
|
||||
searchText: '',
|
||||
tags: [],
|
||||
onTagsChange: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
blocks: [],
|
||||
tabs: [
|
||||
{ key: TabsEnum.Start, name: 'Start' },
|
||||
{ key: TabsEnum.Blocks, name: 'Blocks', disabled: true },
|
||||
{ key: TabsEnum.Tools, name: 'Tools' },
|
||||
],
|
||||
filterElem: <div>filter</div>,
|
||||
}
|
||||
|
||||
it('should render start content and disabled tab tooltip text', () => {
|
||||
render(<Tabs {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('start-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.tabs.startDisabledTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch tabs through click handlers and render tools content with normalized icons', () => {
|
||||
const onActiveTabChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Tabs
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Tools}
|
||||
onActiveTabChange={onActiveTabChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Start'))
|
||||
|
||||
expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start)
|
||||
expect(screen.getByText('tools-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('/console/tool.svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync normalized tools into workflow store state', () => {
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
|
||||
expect(mockSetState).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,532 @@
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import type { ToolValue } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { createCustomCollection } from '@/service/tools'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidateAllBuiltInTools,
|
||||
useInvalidateAllCustomTools,
|
||||
useInvalidateAllMCPTools,
|
||||
useInvalidateAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { Theme } from '@/types/app'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import ToolPicker from '../tool-picker'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockSetSystemFeatures = vi.fn()
|
||||
const mockInvalidateBuiltInTools = vi.fn()
|
||||
const mockInvalidateCustomTools = vi.fn()
|
||||
const mockInvalidateWorkflowTools = vi.fn()
|
||||
const mockInvalidateMcpTools = vi.fn()
|
||||
const mockCreateCustomCollection = vi.mocked(createCustomCollection)
|
||||
const mockInstallPackageFromMarketPlace = vi.fn()
|
||||
const mockCheckInstalled = vi.fn()
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
const mockUseTags = vi.mocked(useTags)
|
||||
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
|
||||
const mockUseAllBuiltInTools = vi.mocked(useAllBuiltInTools)
|
||||
const mockUseAllCustomTools = vi.mocked(useAllCustomTools)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
const mockUseAllMCPTools = vi.mocked(useAllMCPTools)
|
||||
const mockUseInvalidateAllBuiltInTools = vi.mocked(useInvalidateAllBuiltInTools)
|
||||
const mockUseInvalidateAllCustomTools = vi.mocked(useInvalidateAllCustomTools)
|
||||
const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTools)
|
||||
const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools)
|
||||
const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations)
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/plugins/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useTags: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
createCustomCollection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedToolsRecommendations: vi.fn(),
|
||||
useDownloadPlugin: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
useInstallPackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockInstallPackageFromMarketPlace,
|
||||
isPending: false,
|
||||
}),
|
||||
usePluginDeclarationFromMarketPlace: () => ({
|
||||
data: undefined,
|
||||
}),
|
||||
usePluginTaskList: () => ({
|
||||
handleRefetch: vi.fn(),
|
||||
}),
|
||||
useUpdatePackageFromMarketPlace: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: vi.fn(),
|
||||
useAllCustomTools: vi.fn(),
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
useAllMCPTools: vi.fn(),
|
||||
useInvalidateAllBuiltInTools: vi.fn(),
|
||||
useInvalidateAllCustomTools: vi.fn(),
|
||||
useInvalidateAllWorkflowTools: vi.fn(),
|
||||
useInvalidateAllMCPTools: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (payload: unknown) => mockNotify(payload),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({
|
||||
onAdd,
|
||||
onHide,
|
||||
}: {
|
||||
onAdd: (payload: { name: string }) => Promise<void>
|
||||
onHide: () => void
|
||||
}) => (
|
||||
<div data-testid="edit-custom-tool-modal">
|
||||
<button type="button" onClick={() => onAdd({ name: 'collection-a' })}>submit-custom-tool</button>
|
||||
<button type="button" onClick={onHide}>hide-custom-tool</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => mockCheckInstalled(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
|
||||
default: () => ({
|
||||
canInstall: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({
|
||||
refreshPluginList: mockRefreshPluginList,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: vi.fn().mockResolvedValue({ status: 'success' }),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: ({
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: {
|
||||
onSuccess: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="install-from-marketplace">
|
||||
<button type="button" onClick={() => onSuccess()}>complete-featured-install</button>
|
||||
<button type="button" onClick={onClose}>cancel-featured-install</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/utils/var')>()
|
||||
return {
|
||||
...actual,
|
||||
getMarketplaceUrl: () => 'https://marketplace.test/tools',
|
||||
}
|
||||
})
|
||||
|
||||
const createTool = (
|
||||
name: string,
|
||||
label: string,
|
||||
description = `${label} description`,
|
||||
): Tool => ({
|
||||
name,
|
||||
author: 'author',
|
||||
label: {
|
||||
en_US: label,
|
||||
zh_Hans: label,
|
||||
},
|
||||
description: {
|
||||
en_US: description,
|
||||
zh_Hans: description,
|
||||
},
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
})
|
||||
|
||||
const createToolProvider = (
|
||||
overrides: Partial<ToolWithProvider> = {},
|
||||
): ToolWithProvider => ({
|
||||
id: 'provider-1',
|
||||
name: 'provider-one',
|
||||
author: 'Provider Author',
|
||||
description: {
|
||||
en_US: 'Provider description',
|
||||
zh_Hans: 'Provider description',
|
||||
},
|
||||
icon: 'icon',
|
||||
icon_dark: 'icon-dark',
|
||||
label: {
|
||||
en_US: 'Provider One',
|
||||
zh_Hans: 'Provider One',
|
||||
},
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
plugin_id: 'plugin-1',
|
||||
tools: [createTool('tool-a', 'Tool A')],
|
||||
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
|
||||
plugin_unique_identifier: 'plugin-1@1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createToolValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({
|
||||
provider_name: 'provider-a',
|
||||
tool_name: 'tool-a',
|
||||
tool_label: 'Tool A',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'org',
|
||||
author: 'author',
|
||||
name: 'Plugin One',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'plugin-1@1.0.0',
|
||||
icon: 'icon',
|
||||
verified: true,
|
||||
label: { en_US: 'Plugin One' },
|
||||
brief: { en_US: 'Brief' },
|
||||
description: { en_US: 'Plugin description' },
|
||||
introduction: 'Intro',
|
||||
repository: 'https://example.com',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [{ name: 'tag-a' }],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const builtInTools = [
|
||||
createToolProvider({
|
||||
id: 'built-in-1',
|
||||
name: 'built-in-provider',
|
||||
label: { en_US: 'Built-in Provider', zh_Hans: 'Built-in Provider' },
|
||||
tools: [createTool('built-in-tool', 'Built-in Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const customTools = [
|
||||
createToolProvider({
|
||||
id: 'custom-1',
|
||||
name: 'custom-provider',
|
||||
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
|
||||
type: CollectionType.custom,
|
||||
tools: [createTool('weather-tool', 'Weather Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const workflowTools = [
|
||||
createToolProvider({
|
||||
id: 'workflow-1',
|
||||
name: 'workflow-provider',
|
||||
label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' },
|
||||
type: CollectionType.workflow,
|
||||
tools: [createTool('workflow-tool', 'Workflow Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const mcpTools = [
|
||||
createToolProvider({
|
||||
id: 'mcp-1',
|
||||
name: 'mcp-provider',
|
||||
label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' },
|
||||
type: CollectionType.mcp,
|
||||
tools: [createTool('mcp-tool', 'MCP Tool')],
|
||||
}),
|
||||
]
|
||||
|
||||
const renderToolPicker = (props: Partial<React.ComponentProps<typeof ToolPicker>> = {}) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToolPicker
|
||||
disabled={false}
|
||||
trigger={<button type="button">open-picker</button>}
|
||||
isShow={false}
|
||||
onShowChange={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
onSelectMultiple={vi.fn()}
|
||||
selectedTools={[createToolValue()]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ToolPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
enable_marketplace: true,
|
||||
},
|
||||
setSystemFeatures: mockSetSystemFeatures,
|
||||
}))
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
mockUseTags.mockReturnValue({
|
||||
tags: [{ name: 'weather', label: 'Weather' }],
|
||||
tagsMap: { weather: { name: 'weather', label: 'Weather' } },
|
||||
getTagLabel: (name: string) => name,
|
||||
})
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
} as ReturnType<typeof useMarketplacePlugins>)
|
||||
mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<typeof useAllBuiltInTools>)
|
||||
mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType<typeof useAllCustomTools>)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType<typeof useAllWorkflowTools>)
|
||||
mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType<typeof useAllMCPTools>)
|
||||
mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools)
|
||||
mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools)
|
||||
mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools)
|
||||
mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools)
|
||||
mockUseFeaturedToolsRecommendations.mockReturnValue({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useFeaturedToolsRecommendations>)
|
||||
mockCreateCustomCollection.mockResolvedValue(undefined)
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-1',
|
||||
})
|
||||
mockCheckInstalled.mockReturnValue({
|
||||
installedInfo: undefined,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('should request opening when the trigger is clicked unless the picker is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowChange = vi.fn()
|
||||
const disabledOnShowChange = vi.fn()
|
||||
|
||||
renderToolPicker({ onShowChange })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'open-picker' }))
|
||||
expect(onShowChange).toHaveBeenCalledWith(true)
|
||||
|
||||
renderToolPicker({
|
||||
disabled: true,
|
||||
onShowChange: disabledOnShowChange,
|
||||
})
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!)
|
||||
expect(disabledOnShowChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render real search and tool lists, then forward tool selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const onSelectMultiple = vi.fn()
|
||||
const queryPluginsWithDebounced = vi.fn()
|
||||
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
} as ReturnType<typeof useMarketplacePlugins>)
|
||||
|
||||
renderToolPicker({
|
||||
isShow: true,
|
||||
scope: 'custom',
|
||||
onSelect,
|
||||
onSelectMultiple,
|
||||
selectedTools: [],
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('MCP Provider')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'weather')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({
|
||||
query: 'weather',
|
||||
tags: [],
|
||||
category: PluginCategoryEnum.tool,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weather Tool')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('Weather Tool'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider_name: 'custom-provider',
|
||||
tool_name: 'weather-tool',
|
||||
tool_label: 'Weather Tool',
|
||||
}))
|
||||
|
||||
await user.hover(screen.getByText('Custom Provider'))
|
||||
await user.click(screen.getByText('workflow.tabs.addAll'))
|
||||
|
||||
expect(onSelectMultiple).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
provider_name: 'custom-provider',
|
||||
tool_name: 'weather-tool',
|
||||
tool_label: 'Weather Tool',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should create a custom collection from the add button and refresh custom tools', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderToolPicker({
|
||||
isShow: true,
|
||||
supportAddCustomTool: true,
|
||||
})
|
||||
|
||||
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
|
||||
return button.className.includes('bg-components-button-primary-bg')
|
||||
})
|
||||
|
||||
expect(addCustomToolButton).toBeTruthy()
|
||||
|
||||
await user.click(addCustomToolButton!)
|
||||
expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-custom-tool' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateCustomCollection).toHaveBeenCalledWith({ name: 'collection-a' })
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'common.api.actionSuccess',
|
||||
})
|
||||
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('edit-custom-tool-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should invalidate all tool collections after featured install succeeds', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseFeaturedToolsRecommendations.mockReturnValue({
|
||||
plugins: [createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' })],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useFeaturedToolsRecommendations>)
|
||||
|
||||
renderToolPicker({
|
||||
isShow: true,
|
||||
selectedTools: [],
|
||||
})
|
||||
|
||||
const featuredPluginItem = await screen.findByText('Plugin One')
|
||||
await user.hover(featuredPluginItem)
|
||||
await user.click(screen.getByRole('button', { name: 'plugin.installAction' }))
|
||||
expect(await screen.findByTestId('install-from-marketplace')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete-featured-install' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1)
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,122 @@ export type TabsProps = {
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
}
|
||||
|
||||
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
|
||||
if (!list || !currentBasePath)
|
||||
return list
|
||||
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon !== 'string')
|
||||
return provider
|
||||
|
||||
const shouldPrefix = provider.icon.startsWith('/')
|
||||
&& !provider.icon.startsWith(`${currentBasePath}/`)
|
||||
|
||||
if (!shouldPrefix)
|
||||
return provider
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${currentBasePath}${provider.icon}`,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? normalized : list
|
||||
}
|
||||
|
||||
const getStoreToolUpdates = ({
|
||||
state,
|
||||
buildInTools,
|
||||
customTools,
|
||||
workflowTools,
|
||||
mcpTools,
|
||||
}: {
|
||||
state: {
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}) => {
|
||||
const updates: Partial<typeof state> = {}
|
||||
|
||||
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
|
||||
updates.buildInTools = buildInTools
|
||||
if (customTools !== undefined && state.customTools !== customTools)
|
||||
updates.customTools = customTools
|
||||
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
|
||||
updates.workflowTools = workflowTools
|
||||
if (mcpTools !== undefined && state.mcpTools !== mcpTools)
|
||||
updates.mcpTools = mcpTools
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
const TabHeaderItem = ({
|
||||
tab,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
disabledTip,
|
||||
}: {
|
||||
tab: TabsProps['tabs'][number]
|
||||
activeTab: TabsEnum
|
||||
onActiveTabChange: (activeTab: TabsEnum) => void
|
||||
disabledTip: string
|
||||
}) => {
|
||||
const className = cn(
|
||||
'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
// eslint-disable-next-line tailwindcss/no-unknown-classes
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (tab.disabled || activeTab === tab.key)
|
||||
return
|
||||
onActiveTabChange(tab.key)
|
||||
}
|
||||
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position="top"
|
||||
popupClassName="max-w-[200px]"
|
||||
popupContent={disabledTip}
|
||||
>
|
||||
<div
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
@@ -71,51 +187,21 @@ const Tabs: FC<TabsProps> = ({
|
||||
plugins: featuredPlugins = [],
|
||||
isLoading: isFeaturedLoading,
|
||||
} = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
|
||||
|
||||
const normalizeToolList = useMemo(() => {
|
||||
return (list?: ToolWithProvider[]) => {
|
||||
if (!list)
|
||||
return list
|
||||
if (!basePath)
|
||||
return list
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon === 'string') {
|
||||
const icon = provider.icon
|
||||
const shouldPrefix = Boolean(basePath)
|
||||
&& icon.startsWith('/')
|
||||
&& !icon.startsWith(`${basePath}/`)
|
||||
|
||||
if (shouldPrefix) {
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${basePath}${icon}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider
|
||||
})
|
||||
return changed ? normalized : list
|
||||
}
|
||||
}, [basePath])
|
||||
const normalizedBuiltInTools = useMemo(() => normalizeToolList(buildInTools, basePath), [buildInTools])
|
||||
const normalizedCustomTools = useMemo(() => normalizeToolList(customTools, basePath), [customTools])
|
||||
const normalizedWorkflowTools = useMemo(() => normalizeToolList(workflowTools, basePath), [workflowTools])
|
||||
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
|
||||
const disabledTip = t('tabs.startDisabledTip', { ns: 'workflow' })
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.setState((state) => {
|
||||
const updates: Partial<typeof state> = {}
|
||||
const normalizedBuiltIn = normalizeToolList(buildInTools)
|
||||
const normalizedCustom = normalizeToolList(customTools)
|
||||
const normalizedWorkflow = normalizeToolList(workflowTools)
|
||||
const normalizedMCP = normalizeToolList(mcpTools)
|
||||
|
||||
if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn)
|
||||
updates.buildInTools = normalizedBuiltIn
|
||||
if (normalizedCustom !== undefined && state.customTools !== normalizedCustom)
|
||||
updates.customTools = normalizedCustom
|
||||
if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow)
|
||||
updates.workflowTools = normalizedWorkflow
|
||||
if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP)
|
||||
updates.mcpTools = normalizedMCP
|
||||
const updates = getStoreToolUpdates({
|
||||
state,
|
||||
buildInTools: normalizedBuiltInTools,
|
||||
customTools: normalizedCustomTools,
|
||||
workflowTools: normalizedWorkflowTools,
|
||||
mcpTools: normalizedMcpTools,
|
||||
})
|
||||
if (!Object.keys(updates).length)
|
||||
return state
|
||||
return {
|
||||
@@ -123,7 +209,7 @@ const Tabs: FC<TabsProps> = ({
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
}, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools])
|
||||
}, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore])
|
||||
|
||||
return (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
@@ -131,46 +217,15 @@ const Tabs: FC<TabsProps> = ({
|
||||
!noBlocks && (
|
||||
<div className="relative flex bg-background-section-burn pl-1 pt-1">
|
||||
{
|
||||
tabs.map((tab) => {
|
||||
const commonProps = {
|
||||
'className': cn(
|
||||
'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
),
|
||||
'aria-disabled': tab.disabled,
|
||||
'onClick': () => {
|
||||
if (tab.disabled || activeTab === tab.key)
|
||||
return
|
||||
onActiveTabChange(tab.key)
|
||||
},
|
||||
} as const
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position="top"
|
||||
popupClassName="max-w-[200px]"
|
||||
popupContent={t('tabs.startDisabledTip', { ns: 'workflow' })}
|
||||
>
|
||||
<div {...commonProps}>
|
||||
{tab.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
{...commonProps}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
tabs.map(tab => (
|
||||
<TabHeaderItem
|
||||
key={tab.key}
|
||||
tab={tab}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={onActiveTabChange}
|
||||
disabledTip={disabledTip}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
@@ -219,10 +274,10 @@ const Tabs: FC<TabsProps> = ({
|
||||
onSelect={onSelect}
|
||||
tags={tags}
|
||||
canNotSelectMultiple
|
||||
buildInTools={buildInTools || []}
|
||||
customTools={customTools || []}
|
||||
workflowTools={workflowTools || []}
|
||||
mcpTools={mcpTools || []}
|
||||
buildInTools={normalizedBuiltInTools || []}
|
||||
customTools={normalizedCustomTools || []}
|
||||
workflowTools={normalizedWorkflowTools || []}
|
||||
mcpTools={normalizedMcpTools || []}
|
||||
onTagsChange={onTagsChange}
|
||||
isInRAGPipeline={inRAGPipeline}
|
||||
featuredPlugins={featuredPlugins}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Node } from '../../types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import DatasetsDetailProvider from '../provider'
|
||||
import { useDatasetsDetailStore } from '../store'
|
||||
|
||||
const mockFetchDatasets = vi.fn()
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
fetchDatasets: (params: unknown) => mockFetchDatasets(params),
|
||||
}))
|
||||
|
||||
const Consumer = () => {
|
||||
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
|
||||
return <div>{`dataset-count:${datasetCount}`}</div>
|
||||
}
|
||||
|
||||
const createWorkflowNode = (datasetIds: string[] = []): Node => ({
|
||||
id: `node-${datasetIds.join('-') || 'empty'}`,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Knowledge',
|
||||
desc: '',
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
dataset_ids: datasetIds,
|
||||
},
|
||||
} as unknown as Node)
|
||||
|
||||
const createDataset = (id: string): DataSet => ({
|
||||
id,
|
||||
name: `Dataset ${id}`,
|
||||
} as DataSet)
|
||||
|
||||
describe('datasets-detail-store provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchDatasets.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it('should provide the datasets detail store without fetching when no knowledge datasets are selected', () => {
|
||||
render(
|
||||
<DatasetsDetailProvider nodes={[
|
||||
{
|
||||
id: 'node-start',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
} as unknown as Node,
|
||||
]}
|
||||
>
|
||||
<Consumer />
|
||||
</DatasetsDetailProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset-count:0')).toBeInTheDocument()
|
||||
expect(mockFetchDatasets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch unique dataset details from knowledge retrieval nodes and store them', async () => {
|
||||
mockFetchDatasets.mockResolvedValue({
|
||||
data: [createDataset('dataset-1'), createDataset('dataset-2')],
|
||||
})
|
||||
|
||||
render(
|
||||
<DatasetsDetailProvider nodes={[
|
||||
createWorkflowNode(['dataset-1', 'dataset-2']),
|
||||
createWorkflowNode(['dataset-2']),
|
||||
]}
|
||||
>
|
||||
<Consumer />
|
||||
</DatasetsDetailProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatasets).toHaveBeenCalledWith({
|
||||
url: '/datasets',
|
||||
params: {
|
||||
page: 1,
|
||||
ids: ['dataset-1', 'dataset-2'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('dataset-count:2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { Shape } from '../../store/workflow'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import HeaderInNormal from '../header-in-normal'
|
||||
import HeaderInRestoring from '../header-in-restoring'
|
||||
import HeaderInHistory from '../header-in-view-history'
|
||||
|
||||
const mockUseNodes = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleNodeSelect = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
const mockInvalidAllLastRun = vi.fn()
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockRunAndHistory = vi.fn()
|
||||
const mockViewHistory = vi.fn()
|
||||
|
||||
let mockNodesReadOnly = false
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockUseNodes(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }),
|
||||
useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }),
|
||||
useWorkflowRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => mockInvalidAllLastRun,
|
||||
useRestoreWorkflow: () => ({
|
||||
mutateAsync: mockRestoreWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: (payload: unknown) => mockNotify(payload),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../editing-title', () => ({
|
||||
default: () => <div>editing-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../scroll-to-selected-node-button', () => ({
|
||||
default: () => <div>scroll-button</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../env-button', () => ({
|
||||
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../global-variable-button', () => ({
|
||||
default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../run-and-history', () => ({
|
||||
default: (props: object) => {
|
||||
mockRunAndHistory(props)
|
||||
return <div data-testid="run-and-history" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-button', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
version-history
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../restoring-title', () => ({
|
||||
default: () => <div>restoring-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../running-title', () => ({
|
||||
default: () => <div>running-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../view-history', () => ({
|
||||
default: (props: { withText?: boolean }) => {
|
||||
mockViewHistory(props)
|
||||
return <div data-testid="view-history">{props.withText ? 'with-text' : 'icon-only'}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createSelectedNode = (selected = true) => ({
|
||||
id: 'node-selected',
|
||||
data: {
|
||||
selected,
|
||||
},
|
||||
})
|
||||
|
||||
const createBackupDraft = (): NonNullable<Shape['backupDraft']> => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
environmentVariables: [],
|
||||
})
|
||||
|
||||
const createCurrentVersion = (): NonNullable<Shape['currentVersion']> => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 0,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
version: WorkflowVersion.Latest,
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
})
|
||||
|
||||
describe('Header layout components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodesReadOnly = false
|
||||
mockTheme = 'light'
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockRestoreWorkflow.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('HeaderInNormal', () => {
|
||||
it('should render slots, pass read-only state to action buttons, and start restoring mode', () => {
|
||||
mockNodesReadOnly = true
|
||||
mockUseNodes.mockReturnValue([createSelectedNode()])
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInNormal
|
||||
components={{
|
||||
left: <div>left-slot</div>,
|
||||
middle: <div>middle-slot</div>,
|
||||
chatVariableTrigger: <div>chat-trigger</div>,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
showEnvPanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
showVariableInspectPanel: true,
|
||||
showChatVariablePanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('editing-title')).toBeInTheDocument()
|
||||
expect(screen.getByText('scroll-button')).toBeInTheDocument()
|
||||
expect(screen.getByText('left-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('middle-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('chat-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('env-button')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('global-variable-button')).toHaveTextContent('true')
|
||||
expect(mockRunAndHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'version-history' }))
|
||||
|
||||
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-selected', true)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().isRestoring).toBe(true)
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true)
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
expect(store.getState().showVariableInspectPanel).toBe(false)
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInRestoring', () => {
|
||||
it('should cancel restoring mode and reopen the editor state', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isRestoring: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' }))
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().isRestoring).toBe(false)
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should restore the selected version, clear backup state, and forward lifecycle callbacks', async () => {
|
||||
const onRestoreSettled = vi.fn()
|
||||
const deleteAllInspectVars = vi.fn()
|
||||
const currentVersion = createCurrentVersion()
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isRestoring: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
backupDraft: createBackupDraft(),
|
||||
currentVersion,
|
||||
deleteAllInspectVars,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
|
||||
expect(store.getState().isRestoring).toBe(false)
|
||||
expect(store.getState().backupDraft).toBeUndefined()
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(deleteAllInspectVars).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.versionHistory.action.restoreSuccess',
|
||||
})
|
||||
})
|
||||
expect(onRestoreSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInHistory', () => {
|
||||
it('should render the history trigger with text and return to edit mode', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInHistory viewHistoryProps={{ historyUrl: '/history' } as never} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'history-1',
|
||||
} as Shape['historyWorkflowData'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('running-title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('view-history')).toHaveTextContent('with-text')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.goBackToEdit' }))
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().historyWorkflowData).toBeUndefined()
|
||||
expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({
|
||||
withText: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
106
web/app/components/workflow/header/__tests__/index.spec.tsx
Normal file
106
web/app/components/workflow/header/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from '../index'
|
||||
|
||||
let mockPathname = '/apps/demo/workflow'
|
||||
let mockMaximizeCanvas = false
|
||||
let mockWorkflowMode = {
|
||||
normal: true,
|
||||
restoring: false,
|
||||
viewHistory: false,
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useWorkflowMode: () => mockWorkflowMode,
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: <T,>(selector: (state: { maximizeCanvas: boolean }) => T) => selector({
|
||||
maximizeCanvas: mockMaximizeCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const ReactModule = await import('react')
|
||||
|
||||
return {
|
||||
default: (
|
||||
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
|
||||
) => {
|
||||
const DynamicComponent = (props: Record<string, unknown>) => {
|
||||
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
|
||||
|
||||
ReactModule.useEffect(() => {
|
||||
let mounted = true
|
||||
loader().then((mod) => {
|
||||
if (mounted)
|
||||
setLoaded(() => mod.default)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return Loaded ? <Loaded {...props} /> : null
|
||||
}
|
||||
|
||||
return DynamicComponent
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../header-in-normal', () => ({
|
||||
default: () => <div data-testid="header-normal">normal-layout</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../header-in-view-history', () => ({
|
||||
default: () => <div data-testid="header-history">history-layout</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../header-in-restoring', () => ({
|
||||
default: () => <div data-testid="header-restoring">restoring-layout</div>,
|
||||
}))
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/apps/demo/workflow'
|
||||
mockMaximizeCanvas = false
|
||||
mockWorkflowMode = {
|
||||
normal: true,
|
||||
restoring: false,
|
||||
viewHistory: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('should render the normal layout and show the maximize spacer on workflow canvases', () => {
|
||||
mockMaximizeCanvas = true
|
||||
|
||||
const { container } = render(<Header />)
|
||||
|
||||
expect(screen.getByTestId('header-normal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-history')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-restoring')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.h-14.w-\\[52px\\]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should switch between history and restoring layouts and skip the spacer outside canvas routes', async () => {
|
||||
mockPathname = '/apps/demo/logs'
|
||||
mockWorkflowMode = {
|
||||
normal: false,
|
||||
restoring: true,
|
||||
viewHistory: true,
|
||||
}
|
||||
|
||||
const { container } = render(<Header />)
|
||||
|
||||
expect(await screen.findByTestId('header-history')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('header-restoring')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-normal')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.h-14.w-\\[52px\\]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { TestRunMenuRef, TriggerOption } from '../test-run-menu'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import * as React from 'react'
|
||||
import TestRunMenu, { TriggerType } from '../test-run-menu'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => <div onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
|
||||
id: 'user-input',
|
||||
type: TriggerType.UserInput,
|
||||
name: 'User Input',
|
||||
icon: <span>icon</span>,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TestRunMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should run the only enabled option directly and preserve the child click handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const originalOnClick = vi.fn()
|
||||
|
||||
render(
|
||||
<TestRunMenu
|
||||
options={{
|
||||
userInput: createOption(),
|
||||
triggers: [],
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<button onClick={originalOnClick}>Run now</button>
|
||||
</TestRunMenu>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Run now' }))
|
||||
|
||||
expect(originalOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-input' }))
|
||||
})
|
||||
|
||||
it('should expose toggle via ref and select a shortcut when multiple options are available', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
const Harness = () => {
|
||||
const ref = React.useRef<TestRunMenuRef>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => ref.current?.toggle()}>Toggle via ref</button>
|
||||
<TestRunMenu
|
||||
ref={ref}
|
||||
options={{
|
||||
userInput: createOption(),
|
||||
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
|
||||
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<button>Open menu</button>
|
||||
</TestRunMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' }))
|
||||
})
|
||||
fireEvent.keyDown(window, { key: '0' })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' }))
|
||||
expect(screen.getByText('~')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore disabled options in the rendered menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TestRunMenu
|
||||
options={{
|
||||
userInput: createOption({ enabled: false }),
|
||||
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
|
||||
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
>
|
||||
<button>Open menu</button>
|
||||
</TestRunMenu>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open menu' }))
|
||||
|
||||
expect(screen.queryByText('User Input')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Webhook Trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
118
web/app/components/workflow/header/test-run-menu-helpers.tsx
Normal file
118
web/app/components/workflow/header/test-run-menu-helpers.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { MouseEvent, MouseEventHandler, ReactElement } from 'react'
|
||||
import type { TriggerOption } from './test-run-menu'
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
export type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
shortcutKey: string
|
||||
}
|
||||
|
||||
export const getNormalizedShortcutKey = (event: KeyboardEvent) => {
|
||||
return event.key === '`' ? '~' : event.key
|
||||
}
|
||||
|
||||
export const OptionRow = ({
|
||||
option,
|
||||
shortcutKey,
|
||||
onSelect,
|
||||
}: {
|
||||
option: TriggerOption
|
||||
shortcutKey?: string
|
||||
onSelect: (option: TriggerOption) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(option)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
{option.icon}
|
||||
</div>
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useShortcutMenu = ({
|
||||
open,
|
||||
shortcutMappings,
|
||||
handleSelect,
|
||||
}: {
|
||||
open: boolean
|
||||
shortcutMappings: ShortcutMapping[]
|
||||
handleSelect: (option: TriggerOption) => void
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
|
||||
return
|
||||
|
||||
const normalizedKey = getNormalizedShortcutKey(event)
|
||||
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
|
||||
|
||||
if (mapping) {
|
||||
event.preventDefault()
|
||||
handleSelect(mapping.option)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSelect, open, shortcutMappings])
|
||||
}
|
||||
|
||||
export const SingleOptionTrigger = ({
|
||||
children,
|
||||
runSoleOption,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
runSoleOption: () => void
|
||||
}) => {
|
||||
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
}
|
||||
|
||||
if (isValidElement(children)) {
|
||||
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
|
||||
const originalOnClick = childElement.props?.onClick
|
||||
|
||||
// eslint-disable-next-line react/no-clone-element
|
||||
return cloneElement(childElement, {
|
||||
onClick: (event: MouseEvent<HTMLElement>) => {
|
||||
if (typeof originalOnClick === 'function')
|
||||
originalOnClick(event)
|
||||
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,8 @@
|
||||
import type { MouseEvent, MouseEventHandler, ReactElement } from 'react'
|
||||
import {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ShortcutMapping } from './test-run-menu-helpers'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
|
||||
|
||||
export enum TriggerType {
|
||||
UserInput = 'user_input',
|
||||
@@ -52,9 +38,24 @@ export type TestRunMenuRef = {
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
shortcutKey: string
|
||||
const getEnabledOptions = (options: TestRunOptions) => {
|
||||
const flattened: TriggerOption[] = []
|
||||
|
||||
if (options.userInput)
|
||||
flattened.push(options.userInput)
|
||||
if (options.runAll)
|
||||
flattened.push(options.runAll)
|
||||
flattened.push(...options.triggers)
|
||||
|
||||
return flattened.filter(option => option.enabled !== false)
|
||||
}
|
||||
|
||||
const getMenuVisibility = (options: TestRunOptions) => {
|
||||
return {
|
||||
hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput),
|
||||
hasTriggers: options.triggers.some(trigger => trigger.enabled !== false),
|
||||
hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll),
|
||||
}
|
||||
}
|
||||
|
||||
const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
|
||||
@@ -76,6 +77,7 @@ const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
|
||||
return mappings
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/no-forward-ref
|
||||
const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
options,
|
||||
onSelect,
|
||||
@@ -97,17 +99,7 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
setOpen(false)
|
||||
}, [onSelect])
|
||||
|
||||
const enabledOptions = useMemo(() => {
|
||||
const flattened: TriggerOption[] = []
|
||||
|
||||
if (options.userInput)
|
||||
flattened.push(options.userInput)
|
||||
if (options.runAll)
|
||||
flattened.push(options.runAll)
|
||||
flattened.push(...options.triggers)
|
||||
|
||||
return flattened.filter(option => option.enabled !== false)
|
||||
}, [options])
|
||||
const enabledOptions = useMemo(() => getEnabledOptions(options), [options])
|
||||
|
||||
const hasSingleEnabledOption = enabledOptions.length === 1
|
||||
const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined
|
||||
@@ -117,6 +109,12 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
handleSelect(soleEnabledOption)
|
||||
}, [handleSelect, soleEnabledOption])
|
||||
|
||||
useShortcutMenu({
|
||||
open,
|
||||
shortcutMappings,
|
||||
handleSelect,
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
toggle: () => {
|
||||
if (hasSingleEnabledOption) {
|
||||
@@ -128,84 +126,17 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
},
|
||||
}), [hasSingleEnabledOption, runSoleOption])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
|
||||
return
|
||||
|
||||
const normalizedKey = event.key === '`' ? '~' : event.key
|
||||
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
|
||||
|
||||
if (mapping) {
|
||||
event.preventDefault()
|
||||
handleSelect(mapping.option)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSelect, open, shortcutMappings])
|
||||
|
||||
const renderOption = (option: TriggerOption) => {
|
||||
const shortcutKey = shortcutKeyById.get(option.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
{option.icon}
|
||||
</div>
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <OptionRow option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
|
||||
}
|
||||
|
||||
const hasUserInput = !!options.userInput && options.userInput.enabled !== false
|
||||
const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false)
|
||||
const hasRunAll = !!options.runAll && options.runAll.enabled !== false
|
||||
const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options])
|
||||
|
||||
if (hasSingleEnabledOption && soleEnabledOption) {
|
||||
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
}
|
||||
|
||||
if (isValidElement(children)) {
|
||||
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
|
||||
const originalOnClick = childElement.props?.onClick
|
||||
|
||||
return cloneElement(childElement, {
|
||||
onClick: (event: MouseEvent<HTMLElement>) => {
|
||||
if (typeof originalOnClick === 'function')
|
||||
originalOnClick(event)
|
||||
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
<SingleOptionTrigger runSoleOption={runSoleOption}>
|
||||
{children}
|
||||
</span>
|
||||
</SingleOptionTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'react'
|
||||
import { HooksStoreContext, HooksStoreContextProvider } from '../provider'
|
||||
|
||||
const mockRefreshAll = vi.fn()
|
||||
const mockStore = {
|
||||
getState: () => ({
|
||||
refreshAll: mockRefreshAll,
|
||||
}),
|
||||
}
|
||||
|
||||
let mockReactflowState = {
|
||||
d3Selection: null as object | null,
|
||||
d3Zoom: null as object | null,
|
||||
}
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStore: (selector: (state: typeof mockReactflowState) => unknown) => selector(mockReactflowState),
|
||||
}))
|
||||
|
||||
vi.mock('../store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../store')>('../store')
|
||||
return {
|
||||
...actual,
|
||||
createHooksStore: vi.fn(() => mockStore),
|
||||
}
|
||||
})
|
||||
|
||||
const Consumer = () => {
|
||||
const store = useContext(HooksStoreContext)
|
||||
return <div>{store ? 'has-hooks-store' : 'missing-hooks-store'}</div>
|
||||
}
|
||||
|
||||
describe('hooks-store provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReactflowState = {
|
||||
d3Selection: null,
|
||||
d3Zoom: null,
|
||||
}
|
||||
})
|
||||
|
||||
it('should provide the hooks store context without refreshing when the canvas handles are missing', () => {
|
||||
render(
|
||||
<HooksStoreContextProvider>
|
||||
<Consumer />
|
||||
</HooksStoreContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('has-hooks-store')).toBeInTheDocument()
|
||||
expect(mockRefreshAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should refresh the hooks store when both d3Selection and d3Zoom are available', async () => {
|
||||
const handleRun = vi.fn()
|
||||
mockReactflowState = {
|
||||
d3Selection: {},
|
||||
d3Zoom: {},
|
||||
}
|
||||
|
||||
render(
|
||||
<HooksStoreContextProvider handleRun={handleRun}>
|
||||
<Consumer />
|
||||
</HooksStoreContextProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshAll).toHaveBeenCalledWith({
|
||||
handleRun,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
107
web/app/components/workflow/nodes/__tests__/index.spec.tsx
Normal file
107
web/app/components/workflow/nodes/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Node as WorkflowNode } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CUSTOM_NODE } from '../../constants'
|
||||
import { BlockEnum } from '../../types'
|
||||
import CustomNode, { Panel } from '../index'
|
||||
|
||||
vi.mock('../components', () => ({
|
||||
NodeComponentMap: {
|
||||
[BlockEnum.Start]: () => <div>start-node-component</div>,
|
||||
},
|
||||
PanelComponentMap: {
|
||||
[BlockEnum.Start]: () => <div>start-panel-component</div>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../_base/node', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
data: { type: BlockEnum }
|
||||
children: ReactElement
|
||||
}) => (
|
||||
<div>
|
||||
<div>{`base-node:${id}:${data.type}`}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/workflow-panel', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
id,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
data: { type: BlockEnum }
|
||||
children: ReactElement
|
||||
}) => (
|
||||
<div>
|
||||
<div>{`base-panel:${id}:${data.type}`}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createNodeData = (): WorkflowNode['data'] => ({
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
})
|
||||
|
||||
const baseNodeProps = {
|
||||
type: CUSTOM_NODE,
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
isConnectable: true,
|
||||
}
|
||||
|
||||
describe('workflow nodes index', () => {
|
||||
it('should render the mapped node inside the base node shell', () => {
|
||||
render(
|
||||
<CustomNode
|
||||
id="node-1"
|
||||
data={createNodeData()}
|
||||
{...baseNodeProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('base-node:node-1:start')).toBeInTheDocument()
|
||||
expect(screen.getByText('start-node-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the mapped panel inside the base panel shell for custom nodes', () => {
|
||||
render(
|
||||
<Panel
|
||||
type={CUSTOM_NODE}
|
||||
id="node-1"
|
||||
data={createNodeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument()
|
||||
expect(screen.getByText('start-panel-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null for non-custom panel types', () => {
|
||||
const { container } = render(
|
||||
<Panel
|
||||
type="default"
|
||||
id="node-1"
|
||||
data={createNodeData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileTypeItem from '../file-type-item'
|
||||
import FileUploadSetting from '../file-upload-setting'
|
||||
|
||||
const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig)
|
||||
const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit)
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
|
||||
useFileSizeLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<UploadFileSetting> = {}): UploadFileSetting => ({
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
max_length: 2,
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['pdf'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('File upload support components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType<typeof useFileUploadConfig>)
|
||||
mockUseFileSizeLimit.mockReturnValue({
|
||||
imgSizeLimit: 10 * 1024 * 1024,
|
||||
docSizeLimit: 20 * 1024 * 1024,
|
||||
audioSizeLimit: 30 * 1024 * 1024,
|
||||
videoSizeLimit: 40 * 1024 * 1024,
|
||||
maxFileUploadLimit: 10,
|
||||
} as ReturnType<typeof useFileSizeLimit>)
|
||||
})
|
||||
|
||||
describe('FileTypeItem', () => {
|
||||
it('should render built-in file types and toggle the selected type on click', () => {
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<FileTypeItem
|
||||
type={SupportUploadFileTypes.image}
|
||||
selected={false}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('JPG, JPEG, PNG, GIF, WEBP, SVG')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('appDebug.variableConfig.file.image.name'))
|
||||
expect(onToggle).toHaveBeenCalledWith(SupportUploadFileTypes.image)
|
||||
})
|
||||
|
||||
it('should render the custom tag editor and emit custom extensions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCustomFileTypesChange = vi.fn()
|
||||
|
||||
render(
|
||||
<FileTypeItem
|
||||
type={SupportUploadFileTypes.custom}
|
||||
selected
|
||||
onToggle={vi.fn()}
|
||||
customFileTypes={['json']}
|
||||
onCustomFileTypesChange={onCustomFileTypesChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
|
||||
await user.type(input, 'csv')
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(screen.getByText('json')).toBeInTheDocument()
|
||||
expect(onCustomFileTypesChange).toHaveBeenCalledWith(['json', 'csv'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileUploadSetting', () => {
|
||||
it('should update file types, upload methods, and upload limits', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.image.name'))
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image],
|
||||
}))
|
||||
|
||||
await user.click(screen.getByText('URL'))
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
}))
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } })
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
max_length: 5,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should toggle built-in and custom file type selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.document.name'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload({
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
})}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_types: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should support both upload methods and update custom extensions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appDebug.variableConfig.both'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload({
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
})}
|
||||
isMultiple={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
|
||||
await user.type(input, 'csv')
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
allowed_file_extensions: ['pdf', 'csv'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render support file types in the feature panel and hide them when requested', () => {
|
||||
const { rerender } = render(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
inFeaturePanel
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<FileUploadSetting
|
||||
payload={createPayload()}
|
||||
isMultiple={false}
|
||||
inFeaturePanel
|
||||
hideSupportFileType
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
|
||||
import DefaultValue from '../default-value'
|
||||
import ErrorHandleOnNode from '../error-handle-on-node'
|
||||
import ErrorHandleOnPanel from '../error-handle-on-panel'
|
||||
import ErrorHandleTip from '../error-handle-tip'
|
||||
import ErrorHandleTypeSelector from '../error-handle-type-selector'
|
||||
import FailBranchCard from '../fail-branch-card'
|
||||
import { useDefaultValue, useErrorHandle } from '../hooks'
|
||||
import { ErrorHandleTypeEnum } from '../types'
|
||||
|
||||
const { mockDocLink } = vi.hoisted(() => ({
|
||||
mockDocLink: vi.fn((path: string) => `https://docs.example.com${path}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useDocLink: () => mockDocLink,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDefaultValue: vi.fn(),
|
||||
useErrorHandle: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../node-handle', () => ({
|
||||
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />,
|
||||
}))
|
||||
|
||||
const mockUseDefaultValue = vi.mocked(useDefaultValue)
|
||||
const mockUseErrorHandle = vi.mocked(useErrorHandle)
|
||||
const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly
|
||||
|
||||
const baseData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: 'code' as CommonNodeType['type'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => (
|
||||
<ErrorHandleOnNode id={id} data={data} />
|
||||
)
|
||||
|
||||
const renderErrorHandleNode = (data: CommonNodeType) =>
|
||||
renderWorkflowFlowComponent(<div />, {
|
||||
nodes: [createNode({
|
||||
id: 'node-1',
|
||||
type: 'errorHandleNode',
|
||||
data,
|
||||
})],
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: {
|
||||
errorHandleNode: ErrorHandleNodeHarness,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('error-handle path', () => {
|
||||
beforeAll(() => {
|
||||
class MockDOMMatrixReadOnly {
|
||||
inverse() {
|
||||
return this
|
||||
}
|
||||
|
||||
transformPoint(point: { x: number, y: number }) {
|
||||
return point
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'DOMMatrixReadOnly', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MockDOMMatrixReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDocLink.mockImplementation((path: string) => `https://docs.example.com${path}`)
|
||||
mockUseDefaultValue.mockReturnValue({
|
||||
handleFormChange: vi.fn(),
|
||||
})
|
||||
mockUseErrorHandle.mockReturnValue({
|
||||
collapsed: false,
|
||||
setCollapsed: vi.fn(),
|
||||
handleErrorHandleTypeChange: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'DOMMatrixReadOnly', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalDOMMatrixReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
// The error-handle leaf components should expose selectable strategies and contextual help.
|
||||
describe('Leaf Components', () => {
|
||||
it('should render the fail-branch card with the resolved learn-more link', () => {
|
||||
render(<FailBranchCard />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
|
||||
})
|
||||
|
||||
it('should render string forms and surface array forms in the default value editor', () => {
|
||||
const onFormChange = vi.fn()
|
||||
render(
|
||||
<DefaultValue
|
||||
forms={[
|
||||
{ key: 'message', type: VarType.string, value: 'hello' },
|
||||
{ key: 'items', type: VarType.arrayString, value: '["a"]' },
|
||||
]}
|
||||
onFormChange={onFormChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated' } })
|
||||
|
||||
expect(onFormChange).toHaveBeenCalledWith({
|
||||
key: 'message',
|
||||
type: VarType.string,
|
||||
value: 'updated',
|
||||
})
|
||||
expect(screen.getByText('items')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the selector popup and report the selected strategy', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelected = vi.fn()
|
||||
render(
|
||||
<ErrorHandleTypeSelector
|
||||
value={ErrorHandleTypeEnum.none}
|
||||
onSelected={onSelected}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title'))
|
||||
|
||||
expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue)
|
||||
})
|
||||
|
||||
it('should render the error tip only when a strategy exists', () => {
|
||||
const { rerender, container } = render(<ErrorHandleTip />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />)
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument()
|
||||
|
||||
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />)
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The container components should show the correct branch card or default-value editor and propagate actions.
|
||||
describe('Containers', () => {
|
||||
it('should render the fail-branch panel body when the strategy is active', () => {
|
||||
render(
|
||||
<ErrorHandleOnPanel
|
||||
id="node-1"
|
||||
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the default-value panel body and delegate form updates', () => {
|
||||
const handleFormChange = vi.fn()
|
||||
mockUseDefaultValue.mockReturnValue({ handleFormChange })
|
||||
render(
|
||||
<ErrorHandleOnPanel
|
||||
id="node-1"
|
||||
data={baseData({
|
||||
error_strategy: ErrorHandleTypeEnum.defaultValue,
|
||||
default_value: [{ key: 'answer', type: VarType.string, value: 'draft' }],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'next' } })
|
||||
|
||||
expect(handleFormChange).toHaveBeenCalledWith(
|
||||
{ key: 'answer', type: VarType.string, value: 'next' },
|
||||
expect.objectContaining({ error_strategy: ErrorHandleTypeEnum.defaultValue }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide the panel body when the hook reports a collapsed section', () => {
|
||||
mockUseErrorHandle.mockReturnValue({
|
||||
collapsed: true,
|
||||
setCollapsed: vi.fn(),
|
||||
handleErrorHandleTypeChange: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<ErrorHandleOnPanel
|
||||
id="node-1"
|
||||
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the default-value node badge', () => {
|
||||
renderWorkflowFlowComponent(
|
||||
<ErrorHandleOnNode
|
||||
id="node-1"
|
||||
data={baseData({
|
||||
error_strategy: ErrorHandleTypeEnum.defaultValue,
|
||||
})}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the fail-branch node badge when the node throws an exception', () => {
|
||||
const { container } = renderErrorHandleNode(baseData({
|
||||
error_strategy: ErrorHandleTypeEnum.failBranch,
|
||||
_runningStatus: NodeRunningStatus.Exception,
|
||||
}))
|
||||
|
||||
return waitFor(() => {
|
||||
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
|
||||
expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Add from '../add'
|
||||
import InputField from '../index'
|
||||
|
||||
describe('InputField', () => {
|
||||
@@ -14,5 +15,12 @@ describe('InputField', () => {
|
||||
expect(screen.getAllByText('input field')).toHaveLength(2)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the standalone add action button', () => {
|
||||
const { container } = render(<Add />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BoxGroupField, FieldTitle } from '../index'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Box, BoxGroup, BoxGroupField, Field, Group, GroupField } from '../index'
|
||||
|
||||
describe('layout index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The barrel exports should compose the public layout primitives without extra wrappers.
|
||||
// The layout primitives should preserve their composition contracts and collapse behavior.
|
||||
describe('Rendering', () => {
|
||||
it('should render Box and Group with optional border styles', () => {
|
||||
render(
|
||||
<div>
|
||||
<Box withBorderBottom className="box-test">Box content</Box>
|
||||
<Group withBorderBottom className="group-test">Group content</Group>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Box content')).toHaveClass('border-b', 'box-test')
|
||||
expect(screen.getByText('Group content')).toHaveClass('border-b', 'group-test')
|
||||
})
|
||||
|
||||
it('should render BoxGroup and GroupField with nested children', () => {
|
||||
render(
|
||||
<div>
|
||||
<BoxGroup>Inside box group</BoxGroup>
|
||||
<GroupField
|
||||
fieldProps={{
|
||||
fieldTitleProps: {
|
||||
title: 'Grouped field',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Group field body
|
||||
</GroupField>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Inside box group')).toBeInTheDocument()
|
||||
expect(screen.getByText('Grouped field')).toBeInTheDocument()
|
||||
expect(screen.getByText('Group field body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render BoxGroupField from the barrel export', () => {
|
||||
render(
|
||||
<BoxGroupField
|
||||
@@ -25,10 +59,23 @@ describe('layout index', () => {
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FieldTitle from the barrel export', () => {
|
||||
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
|
||||
it('should collapse and expand Field children when supportCollapse is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Field
|
||||
supportCollapse
|
||||
fieldTitleProps={{ title: 'Advanced' }}
|
||||
>
|
||||
<div>Extra details</div>
|
||||
</Field>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extra details')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Advanced'))
|
||||
expect(screen.queryByText('Extra details')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Advanced'))
|
||||
expect(screen.getByText('Extra details')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import MixedVariableTextInput from '../index'
|
||||
|
||||
let capturedPromptEditorProps: PromptEditorProps[] = []
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: ({
|
||||
editable,
|
||||
value,
|
||||
workflowVariableBlock,
|
||||
onChange,
|
||||
}: PromptEditorProps) => {
|
||||
capturedPromptEditorProps.push({
|
||||
editable,
|
||||
value,
|
||||
onChange,
|
||||
workflowVariableBlock,
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-testid="prompt-editor">
|
||||
<div data-testid="editable-flag">{editable ? 'editable' : 'readonly'}</div>
|
||||
<div data-testid="value-flag">{value || 'empty'}</div>
|
||||
<button type="button" onClick={() => onChange?.('updated text')}>trigger-change</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('MixedVariableTextInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedPromptEditorProps = []
|
||||
})
|
||||
|
||||
it('should pass workflow variable metadata to the prompt editor and include system variables for start nodes', () => {
|
||||
const nodesOutputVars: NodeOutPutVar[] = [{
|
||||
nodeId: 'node-1',
|
||||
title: 'Question Node',
|
||||
vars: [],
|
||||
}]
|
||||
const availableNodes: Node[] = [
|
||||
{
|
||||
id: 'start-node',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start Node',
|
||||
desc: 'Start description',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'llm-node',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: 'LLM Node',
|
||||
desc: 'LLM description',
|
||||
type: BlockEnum.LLM,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<MixedVariableTextInput
|
||||
nodesOutputVars={nodesOutputVars}
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
const latestProps = capturedPromptEditorProps.at(-1)
|
||||
|
||||
expect(latestProps?.editable).toBe(true)
|
||||
expect(latestProps?.workflowVariableBlock?.variables).toHaveLength(1)
|
||||
expect(latestProps?.workflowVariableBlock?.workflowNodesMap).toEqual({
|
||||
'start-node': {
|
||||
title: 'Start Node',
|
||||
type: 'start',
|
||||
},
|
||||
'sys': {
|
||||
title: 'workflow.blocks.start',
|
||||
type: 'start',
|
||||
},
|
||||
'llm-node': {
|
||||
title: 'LLM Node',
|
||||
type: 'llm',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should forward read-only state, current value, and change callbacks', async () => {
|
||||
const onChange = vi.fn()
|
||||
const { findByRole, getByTestId } = render(
|
||||
<MixedVariableTextInput
|
||||
readOnly
|
||||
value="seed value"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getByTestId('editable-flag')).toHaveTextContent('readonly')
|
||||
expect(getByTestId('value-flag')).toHaveTextContent('seed value')
|
||||
|
||||
const changeButton = await findByRole('button', { name: 'trigger-change' })
|
||||
changeButton.click()
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('updated text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
|
||||
import Placeholder from '../placeholder'
|
||||
|
||||
const mockEditorUpdate = vi.fn((callback: () => void) => callback())
|
||||
const mockDispatchCommand = vi.fn()
|
||||
const mockInsertNodes = vi.fn()
|
||||
const mockTextNode = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
update: mockEditorUpdate,
|
||||
dispatchCommand: mockDispatchCommand,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('lexical', () => ({
|
||||
$insertNodes: vi.fn(),
|
||||
FOCUS_COMMAND: 'focus-command',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({
|
||||
CustomTextNode: class MockCustomTextNode {
|
||||
value: string
|
||||
|
||||
constructor(value: string) {
|
||||
this.value = value
|
||||
mockTextNode(value)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Mixed variable placeholder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue)
|
||||
vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes))
|
||||
})
|
||||
|
||||
it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => {
|
||||
const parentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<Placeholder />
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
expect(mockTextNode).toHaveBeenCalledWith('')
|
||||
expect(mockInsertNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
|
||||
})
|
||||
|
||||
it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => {
|
||||
render(<Placeholder />)
|
||||
|
||||
const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2')
|
||||
const event = createEvent.mouseDown(shortcut)
|
||||
fireEvent(shortcut, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(mockTextNode).toHaveBeenCalledWith('/')
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,268 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { FlowType } from '@/types/common'
|
||||
import ChangeBlock from '../change-block'
|
||||
import PanelOperatorPopup from '../panel-operator-popup'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
|
||||
<div>
|
||||
<div>{trigger()}</div>
|
||||
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
|
||||
<div>{`show-start:${String(showStartTab)}`}</div>
|
||||
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
|
||||
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
|
||||
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
|
||||
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks-store', () => ({
|
||||
useHooksStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseHooksStore = vi.mocked(useHooksStore)
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
describe('panel-operator details', () => {
|
||||
const handleNodeChange = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
const handleNodesDuplicate = vi.fn()
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodesCopy = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
isUndeletable: false,
|
||||
description: 'Node description',
|
||||
author: 'Dify',
|
||||
helpLinkUri: 'https://docs.example.com/node',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeChange,
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
|
||||
})
|
||||
|
||||
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
|
||||
describe('Internal Actions', () => {
|
||||
it('should select a replacement block through ChangeBlock', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ChangeBlock
|
||||
nodeId="node-1"
|
||||
nodeData={{ type: BlockEnum.Code } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('select-http'))
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
|
||||
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
|
||||
})
|
||||
|
||||
it('should expose trigger and start-node specific block selector options', () => {
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValueOnce(true)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValueOnce([] as any)
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangeBlock
|
||||
nodeId="trigger-node"
|
||||
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:http-request')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
|
||||
|
||||
mockUseAvailableBlocks.mockReturnValueOnce({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [BlockEnum.Code],
|
||||
availableNextBlocks: [],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
|
||||
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
|
||||
rerender(
|
||||
<ChangeBlock
|
||||
nodeId="start-node"
|
||||
nodeData={{ type: BlockEnum.Start } as any}
|
||||
sourceHandle="source"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('available:code')).toBeInTheDocument()
|
||||
expect(screen.getByText('show-start:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
|
||||
expect(screen.getByText('force-start:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-1"
|
||||
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
|
||||
},
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
await user.click(screen.getByText('workflow.common.copy'))
|
||||
await user.click(screen.getByText('workflow.common.duplicate'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
|
||||
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
|
||||
})
|
||||
|
||||
it('should render workflow-tool and readonly popup variants', () => {
|
||||
mockUseAllWorkflowTools.mockReturnValueOnce({
|
||||
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
|
||||
} as any)
|
||||
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<PanelOperatorPopup
|
||||
id="node-2"
|
||||
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: true,
|
||||
isSingleton: true,
|
||||
isUndeletable: true,
|
||||
description: 'Read only node',
|
||||
author: 'Dify',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
|
||||
rerender(
|
||||
<PanelOperatorPopup
|
||||
id="node-3"
|
||||
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
|
||||
onClosePopup={vi.fn()}
|
||||
showHelpLink={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SupportVarInput from '../index'
|
||||
|
||||
describe('SupportVarInput', () => {
|
||||
it('should render plain text, highlighted variables, and preserved line breaks', () => {
|
||||
render(<SupportVarInput value={'Hello {{user_name}}\nWorld'} />)
|
||||
|
||||
expect(screen.getByText('World').closest('[title]')).toHaveAttribute('title', 'Hello {{user_name}}\nWorld')
|
||||
expect(screen.getByText('user_name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('World')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the focused child content and call onFocus when activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onFocus = vi.fn()
|
||||
|
||||
render(
|
||||
<SupportVarInput
|
||||
isFocus
|
||||
value="draft"
|
||||
onFocus={onFocus}
|
||||
>
|
||||
<input aria-label="inline-editor" />
|
||||
</SupportVarInput>,
|
||||
)
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: 'inline-editor' })
|
||||
expect(editor).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('draft')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(editor)
|
||||
|
||||
expect(onFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should keep the static preview visible when the input is read-only', () => {
|
||||
render(
|
||||
<SupportVarInput
|
||||
isFocus
|
||||
readonly
|
||||
value="readonly content"
|
||||
>
|
||||
<input aria-label="hidden-editor" />
|
||||
</SupportVarInput>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('readonly content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import AssignedVarReferencePopup from '../assigned-var-reference-popup'
|
||||
|
||||
const mockVarReferenceVars = vi.fn()
|
||||
|
||||
vi.mock('../var-reference-vars', () => ({
|
||||
default: ({
|
||||
vars,
|
||||
onChange,
|
||||
itemWidth,
|
||||
isSupportFileVar,
|
||||
}: {
|
||||
vars: NodeOutPutVar[]
|
||||
onChange: (value: ValueSelector, item: Var) => void
|
||||
itemWidth?: number
|
||||
isSupportFileVar?: boolean
|
||||
}) => {
|
||||
mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar })
|
||||
return <div data-testid="var-reference-vars">{vars.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createOutputVar = (overrides: Partial<NodeOutPutVar> = {}): NodeOutPutVar => ({
|
||||
nodeId: 'node-1',
|
||||
title: 'Node One',
|
||||
vars: [{
|
||||
variable: 'answer',
|
||||
type: VarType.string,
|
||||
}],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AssignedVarReferencePopup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the empty state when there are no assigned variables', () => {
|
||||
render(
|
||||
<AssignedVarReferencePopup
|
||||
vars={[]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.noAssignedVars')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.assignedVarsDescription')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('var-reference-vars')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should delegate populated variable lists to the variable picker with file support enabled', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<AssignedVarReferencePopup
|
||||
vars={[createOutputVar()]}
|
||||
itemWidth={280}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1')
|
||||
expect(mockVarReferenceVars).toHaveBeenCalledWith({
|
||||
vars: [createOutputVar()],
|
||||
onChange,
|
||||
itemWidth: 280,
|
||||
isSupportFileVar: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { VariableLabelInNode, VariableLabelInText } from '../index'
|
||||
import VariableIcon from '../base/variable-icon'
|
||||
import VariableLabel from '../base/variable-label'
|
||||
import VariableName from '../base/variable-name'
|
||||
import VariableNodeLabel from '../base/variable-node-label'
|
||||
import { VariableIconWithColor, VariableLabelInEditor, VariableLabelInNode, VariableLabelInSelect, VariableLabelInText } from '../index'
|
||||
|
||||
describe('variable-label index', () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,5 +44,96 @@ describe('variable-label index', () => {
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the select variant with the full variable path', () => {
|
||||
render(
|
||||
<VariableLabelInSelect
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['source-node', 'payload', 'answer']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('payload.answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the editor variant with selected styles and inline error feedback', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<VariableLabelInEditor
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['source-node', 'payload']}
|
||||
isSelected
|
||||
errorMsg="Invalid variable"
|
||||
rightSlot={<span>suffix</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const badge = screen.getByText('payload').closest('div')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('suffix')).toBeInTheDocument()
|
||||
|
||||
await user.hover(screen.getByText('payload'))
|
||||
|
||||
expect(container.querySelector('[data-icon="Warning"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should render the icon helpers for environment and exception variables', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<VariableIcon variables={['env', 'API_KEY']} />
|
||||
<VariableIconWithColor
|
||||
variables={['conversation', 'message']}
|
||||
isExceptionVariable
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render the base variable name with shortened path and title', () => {
|
||||
render(
|
||||
<VariableName
|
||||
variables={['node-id', 'payload', 'answer']}
|
||||
notShowFullPath
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('answer')).toHaveAttribute('title', 'answer')
|
||||
})
|
||||
|
||||
it('should render the base node label only when node type exists', () => {
|
||||
const { container, rerender } = render(<VariableNodeLabel />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(
|
||||
<VariableNodeLabel
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Code Node"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Code Node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the base label with variable type and right slot', () => {
|
||||
render(
|
||||
<VariableLabel
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['sys', 'query']}
|
||||
variableType={VarType.string}
|
||||
rightSlot={<span>slot</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('String')).toBeInTheDocument()
|
||||
expect(screen.getByText('slot')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
|
||||
import type { AgentNodeType } from '../types'
|
||||
import type { StrategyParamItem } from '@/app/components/plugins/types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { VarType as ToolVarType } from '../../tool/types'
|
||||
import { ModelBar } from '../components/model-bar'
|
||||
import { ToolIcon } from '../components/tool-icon'
|
||||
import Node from '../node'
|
||||
import Panel from '../panel'
|
||||
import { AgentFeature } from '../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
let mockTextGenerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
|
||||
let mockModerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
|
||||
let mockRerankModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
|
||||
let mockSpeech2TextModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
|
||||
let mockTextEmbeddingModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
|
||||
let mockTtsModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
|
||||
|
||||
let mockBuiltInTools: Array<any> | undefined = []
|
||||
let mockCustomTools: Array<any> | undefined = []
|
||||
let mockWorkflowTools: Array<any> | undefined = []
|
||||
let mockMcpTools: Array<any> | undefined = []
|
||||
let mockMarketplaceIcon: string | Record<string, string> | undefined
|
||||
|
||||
const mockResetEditor = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: (modelType: ModelTypeEnum) => {
|
||||
if (modelType === ModelTypeEnum.textGeneration)
|
||||
return { data: mockTextGenerationModels }
|
||||
if (modelType === ModelTypeEnum.moderation)
|
||||
return { data: mockModerationModels }
|
||||
if (modelType === ModelTypeEnum.rerank)
|
||||
return { data: mockRerankModels }
|
||||
if (modelType === ModelTypeEnum.speech2text)
|
||||
return { data: mockSpeech2TextModels }
|
||||
if (modelType === ModelTypeEnum.textEmbedding)
|
||||
return { data: mockTextEmbeddingModels }
|
||||
return { data: mockTtsModels }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, modelList }: any) => (
|
||||
<div>{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: any) => <div>{`indicator:${color}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),
|
||||
useAllCustomTools: () => ({ data: mockCustomTools }),
|
||||
useAllWorkflowTools: () => ({ data: mockWorkflowTools }),
|
||||
useAllMCPTools: () => ({ data: mockMcpTools }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ icon, background }: any) => <div>{`app-icon:${background}:${icon}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
||||
Group: () => <div>group-icon</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/get-icon', () => ({
|
||||
getIconFromMarketPlace: () => mockMarketplaceIcon,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/group', () => ({
|
||||
Group: ({ label, children }: any) => <div><div>{label}</div>{children}</div>,
|
||||
GroupLabel: ({ className, children }: any) => <div className={className}>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({
|
||||
SettingItem: ({ label, status, tooltip, children }: any) => <div>{label}:{status}:{tooltip}:{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: ({ title, children }: any) => <div><div>{title}</div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({
|
||||
AgentStrategy: ({ onStrategyChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStrategyChange({
|
||||
agent_strategy_provider_name: 'provider/updated',
|
||||
agent_strategy_name: 'updated-strategy',
|
||||
agent_strategy_label: 'Updated Strategy',
|
||||
agent_output_schema: { properties: { extra: { type: 'string', description: 'extra output' } } },
|
||||
plugin_unique_identifier: 'provider/updated:1.0.0',
|
||||
meta: { version: '2.0.0' },
|
||||
})}
|
||||
>
|
||||
change-strategy
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
MCPToolAvailabilityProvider: ({ children }: any) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
|
||||
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ window: { enabled: true, size: 8 }, query_prompt_template: 'history' })}>change-memory</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
default: ({ children }: any) => <div>{children}</div>,
|
||||
VarItem: ({ name, type, description }: any) => <div>{`${name}:${type}:${description}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: () => <div>split</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({
|
||||
setControlPromptEditorRerenderKey: mockResetEditor,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/plugin-version-feature', () => ({
|
||||
isSupportMCP: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseConfig = vi.mocked(useConfig)
|
||||
|
||||
const createStrategyParam = (
|
||||
name: string,
|
||||
type: FormTypeEnum,
|
||||
required: boolean,
|
||||
): StrategyParamItem => ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
label: { en_US: name } as StrategyParamItem['label'],
|
||||
help: { en_US: `${name} help` } as StrategyParamItem['help'],
|
||||
placeholder: { en_US: `${name} placeholder` } as StrategyParamItem['placeholder'],
|
||||
scope: 'global',
|
||||
default: null,
|
||||
options: [],
|
||||
template: { enabled: false },
|
||||
auto_generate: { type: 'none' },
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
type: BlockEnum.Agent,
|
||||
output_schema: {},
|
||||
agent_strategy_provider_name: 'provider/agent',
|
||||
agent_strategy_name: 'react',
|
||||
agent_strategy_label: 'React Agent',
|
||||
agent_parameters: {
|
||||
modelParam: { type: ToolVarType.constant, value: { provider: 'openai', model: 'gpt-4o' } },
|
||||
toolParam: { type: ToolVarType.constant, value: { provider_name: 'author/tool-a' } },
|
||||
multiToolParam: { type: ToolVarType.constant, value: [{ provider_name: 'author/tool-b' }] },
|
||||
},
|
||||
meta: { version: '1.0.0' } as any,
|
||||
plugin_unique_identifier: 'provider/agent:1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
setInputs: vi.fn(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
currentStrategy: {
|
||||
identity: {
|
||||
author: 'provider',
|
||||
name: 'react',
|
||||
icon: 'icon',
|
||||
label: { en_US: 'React Agent' } as any,
|
||||
provider: 'provider/agent',
|
||||
},
|
||||
parameters: [
|
||||
createStrategyParam('modelParam', FormTypeEnum.modelSelector, true),
|
||||
createStrategyParam('optionalModel', FormTypeEnum.modelSelector, false),
|
||||
createStrategyParam('toolParam', FormTypeEnum.toolSelector, false),
|
||||
createStrategyParam('multiToolParam', FormTypeEnum.multiToolSelector, false),
|
||||
],
|
||||
description: { en_US: 'agent description' } as any,
|
||||
output_schema: {},
|
||||
features: [AgentFeature.HISTORY_MESSAGES],
|
||||
},
|
||||
formData: {},
|
||||
onFormChange: vi.fn(),
|
||||
currentStrategyStatus: {
|
||||
plugin: { source: 'marketplace', installed: true },
|
||||
isExistInPlugin: false,
|
||||
},
|
||||
strategyProvider: undefined,
|
||||
pluginDetail: {
|
||||
declaration: {
|
||||
label: 'Mock Plugin',
|
||||
},
|
||||
} as any,
|
||||
availableVars: [],
|
||||
availableNodesWithParent: [],
|
||||
outputSchema: [{ name: 'jsonField', type: 'String', description: 'json output' }],
|
||||
handleMemoryChange: vi.fn(),
|
||||
isChatMode: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps: PanelProps = {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
}
|
||||
|
||||
describe('agent path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTextGenerationModels = [{ provider: 'openai', models: [{ model: 'gpt-4o' }] }]
|
||||
mockModerationModels = []
|
||||
mockRerankModels = []
|
||||
mockSpeech2TextModels = []
|
||||
mockTextEmbeddingModels = []
|
||||
mockTtsModels = []
|
||||
mockBuiltInTools = [{ name: 'author/tool-a', is_team_authorization: true, icon: 'https://example.com/icon-a.png' }]
|
||||
mockCustomTools = []
|
||||
mockWorkflowTools = [{ id: 'author/tool-b', is_team_authorization: false, icon: { content: 'B', background: '#fff' } }]
|
||||
mockMcpTools = []
|
||||
mockMarketplaceIcon = 'https://example.com/marketplace.png'
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
describe('Path Integration', () => {
|
||||
it('should render model bars for missing, installed, and missing-install models', () => {
|
||||
const { rerender, container } = render(<ModelBar />)
|
||||
|
||||
expect(container).toHaveTextContent('no-model:0')
|
||||
expect(screen.getByText('indicator:red')).toBeInTheDocument()
|
||||
|
||||
rerender(<ModelBar provider="openai" model="gpt-4o" />)
|
||||
expect(container).toHaveTextContent('openai/gpt-4o:1')
|
||||
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<ModelBar provider="openai" model="gpt-4.1" />)
|
||||
expect(container).toHaveTextContent('openai/gpt-4.1:1')
|
||||
expect(screen.getByText('indicator:red')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tool icons across loading, marketplace fallback, authorization warning, and fetch-error states', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(<ToolIcon id="tool-0" providerName="author/tool-a" />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.error(screen.getByRole('img', { name: 'tool icon' }))
|
||||
expect(screen.getByText('group-icon')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
const secondRender = render(<ToolIcon id="tool-1" providerName="author/tool-b" />)
|
||||
expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument()
|
||||
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
|
||||
|
||||
mockBuiltInTools = undefined
|
||||
secondRender.rerender(<ToolIcon id="tool-2" providerName="author/tool-c" />)
|
||||
expect(screen.getByText('group-icon')).toBeInTheDocument()
|
||||
|
||||
mockBuiltInTools = []
|
||||
secondRender.rerender(<ToolIcon id="tool-3" providerName="market/tool-d" />)
|
||||
expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument()
|
||||
await user.unhover(screen.getByRole('img', { name: 'tool icon' }))
|
||||
})
|
||||
|
||||
it('should render strategy, models, and toolbox entries in the node', () => {
|
||||
const { container } = render(
|
||||
<Node
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/workflow\.nodes\.agent\.strategy\.shortLabel/)).toBeInTheDocument()
|
||||
expect(container).toHaveTextContent('React Agent')
|
||||
expect(screen.getByText('workflow.nodes.agent.model')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.toolbox')).toBeInTheDocument()
|
||||
expect(container).toHaveTextContent('openai/gpt-4o:1')
|
||||
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the panel, update the selected strategy, and expose memory plus output vars', async () => {
|
||||
const user = userEvent.setup()
|
||||
const config = createConfigResult()
|
||||
mockUseConfig.mockReturnValue(config)
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument()
|
||||
expect(screen.getByText('jsonField:String:json output')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'change-strategy' }))
|
||||
expect(config.setInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent_strategy_provider_name: 'provider/updated',
|
||||
agent_strategy_name: 'updated-strategy',
|
||||
agent_strategy_label: 'Updated Strategy',
|
||||
plugin_unique_identifier: 'provider/updated:1.0.0',
|
||||
}))
|
||||
expect(mockResetEditor).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'change-memory' }))
|
||||
expect(config.handleMemoryChange).toHaveBeenCalledWith({
|
||||
window: { enabled: true, size: 8 },
|
||||
query_prompt_template: 'history',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,514 @@
|
||||
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
|
||||
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import OperationSelector from '../components/operation-selector'
|
||||
import VarList from '../components/var-list'
|
||||
import Node from '../node'
|
||||
import Panel from '../panel'
|
||||
import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockHandleAddOperationItem = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({
|
||||
default: ({ children }: any) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => (
|
||||
<div>
|
||||
<div>{Array.isArray(value) ? value.join('.') : String(value ?? '')}</div>
|
||||
{valueTypePlaceHolder && <div>{`type:${valueTypePlaceHolder}`}</div>}
|
||||
{popupFor === 'toAssigned' && (
|
||||
<div>{`filter:${String(filterVar?.({ nodeId: 'node-1', variable: 'count', type: VarType.string }))}:${String(filterVar?.({ nodeId: 'node-2', variable: 'other', type: VarType.string }))}`}</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpen?.()
|
||||
onChange(popupFor === 'assigned' ? ['node-1', 'count'] : ['node-2', 'result'])
|
||||
}}
|
||||
>
|
||||
{placeholder || popupFor || 'pick-var'}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange }: any) => (
|
||||
<textarea
|
||||
aria-label="code-editor"
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/bool-value', () => ({
|
||||
default: ({ value, onChange }: any) => (
|
||||
<button type="button" onClick={() => onChange(!value)}>
|
||||
{`bool:${String(value)}`}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
|
||||
VariableLabelInNode: ({ variables, nodeTitle, rightSlot }: any) => (
|
||||
<div>
|
||||
<span>{nodeTitle}</span>
|
||||
<span>{variables.join('.')}</span>
|
||||
{rightSlot}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useHandleAddOperationItem: () => mockHandleAddOperationItem,
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseConfig = vi.mocked(useConfig)
|
||||
|
||||
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
|
||||
variable_selector: ['node-1', 'count'],
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
operation: WriteMode.overwrite,
|
||||
value: ['node-2', 'result'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
|
||||
title: 'Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
version: '2',
|
||||
items: [createOperation()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
handleOperationListChanges: vi.fn(),
|
||||
getAssignedVarType: vi.fn(() => VarType.string),
|
||||
getToAssignedVarType: vi.fn(() => VarType.string),
|
||||
writeModeTypes: [WriteMode.overwrite, WriteMode.clear, WriteMode.set],
|
||||
writeModeTypesArr: [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend],
|
||||
writeModeTypesNum,
|
||||
filterAssignedVar: vi.fn(() => true),
|
||||
filterToAssignedVar: vi.fn(() => true),
|
||||
getAvailableVars: vi.fn(() => []),
|
||||
filterVar: vi.fn(() => vi.fn(() => true)),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps: PanelProps = {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
}
|
||||
|
||||
describe('assigner path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHandleAddOperationItem.mockReturnValue([createOperation(), createOperation({ variable_selector: [] })])
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
describe('Path Integration', () => {
|
||||
it('should open the operation selector and choose number operations', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<OperationSelector
|
||||
value={WriteMode.overwrite}
|
||||
onSelect={onSelect}
|
||||
assignedVarType={VarType.number}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={[WriteMode.increment]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.clear')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.set')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.+=')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.+='))
|
||||
expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment })
|
||||
})
|
||||
|
||||
it('should not open a disabled operation selector', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<OperationSelector
|
||||
value={WriteMode.overwrite}
|
||||
onSelect={vi.fn()}
|
||||
disabled
|
||||
assignedVarType={VarType.string}
|
||||
writeModeTypes={[WriteMode.overwrite]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
|
||||
expect(screen.queryByText('workflow.nodes.assigner.operations.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty and populated variable lists across constant editors', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.noVarTip')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ variable_selector: [], value: [] })]}
|
||||
onChange={onChange}
|
||||
onOpen={onOpen}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.string)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.string)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.selectAssignedVariable'))
|
||||
expect(onOpen).toHaveBeenCalledWith(0)
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{
|
||||
variable_selector: ['node-1', 'count'],
|
||||
operation: WriteMode.overwrite,
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
value: undefined,
|
||||
},
|
||||
], ['node-1', 'count'])
|
||||
|
||||
onChange.mockClear()
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.boolean)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.string)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('filter:false:true')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.set'))
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: false,
|
||||
}),
|
||||
])
|
||||
|
||||
onChange.mockClear()
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.setParameter'))
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] }),
|
||||
], ['node-2', 'result'])
|
||||
|
||||
onChange.mockClear()
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.set, value: 'hello' })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.string)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.string)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated text' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({ operation: WriteMode.set, value: 'updated text' }),
|
||||
], 'updated text')
|
||||
|
||||
onChange.mockClear()
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.set, value: 3 })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.number)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.number)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('3'), { target: { value: '5' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({ operation: WriteMode.set, value: 5 }),
|
||||
], 5)
|
||||
|
||||
onChange.mockClear()
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.set, value: false })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.boolean)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.boolean)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'bool:false' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({ operation: WriteMode.set, value: true }),
|
||||
], true)
|
||||
|
||||
onChange.mockClear()
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.set, value: '{\"a\":1}' })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.object)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.object)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{\"a\":2}' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({ operation: WriteMode.set, value: '{\"a\":2}' }),
|
||||
], '{"a":2}')
|
||||
|
||||
onChange.mockClear()
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.increment, value: 2 })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => true)}
|
||||
filterToAssignedVar={vi.fn(() => true)}
|
||||
getAssignedVarType={vi.fn(() => VarType.number)}
|
||||
getToAssignedVarType={vi.fn(() => VarType.number)}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={writeModeTypesNum}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '4' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
createOperation({ operation: WriteMode.increment, value: 4 }),
|
||||
], 4)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
await user.click(buttons.at(-1)!)
|
||||
expect(onChange).toHaveBeenLastCalledWith([])
|
||||
})
|
||||
|
||||
it('should render version 2 and legacy node previews', () => {
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={createData({
|
||||
items: [createOperation({ variable_selector: [] })],
|
||||
})}
|
||||
/>,
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
|
||||
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.varNotSet')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={createData({
|
||||
items: [createOperation()],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('node-1.count')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={{
|
||||
title: 'Legacy Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
assigned_variable_selector: ['sys', 'query'],
|
||||
write_mode: WriteMode.append,
|
||||
} as any}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
expect(screen.getByText('sys.query')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should skip empty version 2 items and resolve system variables without an operation badge', () => {
|
||||
renderWorkflowFlowComponent(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={createData({
|
||||
items: [
|
||||
createOperation({ variable_selector: [] }),
|
||||
createOperation({
|
||||
variable_selector: ['sys', 'query'],
|
||||
operation: undefined,
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
|
||||
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
expect(screen.getByText('sys.query')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.assigner.operations.over-write')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null for legacy nodes without assigned variables and resolve non-system legacy vars', () => {
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={{
|
||||
title: 'Legacy Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
assigned_variable_selector: [],
|
||||
write_mode: WriteMode.append,
|
||||
} as any}
|
||||
/>,
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
|
||||
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.assigner.operations.append')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('node-1.count')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={{
|
||||
title: 'Legacy Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
assigned_variable_selector: ['node-1', 'count'],
|
||||
write_mode: WriteMode.append,
|
||||
} as any}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('node-1.count')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should add panel operations with the real variable list inside the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const config = createConfigResult({
|
||||
inputs: createData(),
|
||||
})
|
||||
mockUseConfig.mockReturnValue(config)
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="assigner-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getAllByRole('button')[0])
|
||||
|
||||
expect(mockHandleAddOperationItem).toHaveBeenCalledWith(createData().items)
|
||||
expect(config.handleOperationListChanges).toHaveBeenCalledWith([
|
||||
createOperation(),
|
||||
createOperation({ variable_selector: [] }),
|
||||
])
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.variables')).toBeInTheDocument()
|
||||
expect(screen.getByText('node-1.count')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CodeDependency } from '../types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DependencyPicker from '../dependency-picker'
|
||||
|
||||
const dependencies: CodeDependency[] = [
|
||||
{ name: 'numpy', version: '1.0.0' },
|
||||
{ name: 'pandas', version: '2.0.0' },
|
||||
]
|
||||
|
||||
describe('DependencyPicker', () => {
|
||||
it('should open the dependency list, filter by search text, and select a new dependency', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<DependencyPicker
|
||||
value={dependencies[0]!}
|
||||
available_dependencies={dependencies}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('numpy')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('numpy'))
|
||||
await user.type(screen.getByRole('textbox'), 'pan')
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('pan')
|
||||
expect(screen.getByText('pandas')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('pandas'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(dependencies[1])
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DocExtractorNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import Node from '../node'
|
||||
import Panel from '../panel'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
let mockLocale = 'en-US'
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useNodes: () => [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
title: 'Input Files',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
|
||||
VariableLabelInNode: ({
|
||||
variables,
|
||||
nodeTitle,
|
||||
nodeType,
|
||||
}: {
|
||||
variables: string[]
|
||||
nodeTitle?: string
|
||||
nodeType?: BlockEnum
|
||||
}) => <div>{`${nodeTitle}:${nodeType}:${variables.join('.')}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
__esModule: true,
|
||||
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>split</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (value: string[]) => void
|
||||
}) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({
|
||||
useNodeHelpLink: () => 'https://docs.example.com/document-extractor',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileSupportTypes: () => ({
|
||||
data: {
|
||||
allowed_extensions: ['PDF', 'md', 'md', 'DOCX'],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale,
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseConfig = vi.mocked(useConfig)
|
||||
|
||||
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
|
||||
title: 'Document Extractor',
|
||||
desc: '',
|
||||
type: BlockEnum.DocExtractor,
|
||||
variable_selector: ['node-1', 'files'],
|
||||
is_array_file: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
handleVarChanges: vi.fn(),
|
||||
filterVar: () => true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps: PanelProps = {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
}
|
||||
|
||||
describe('document-extractor path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocale = 'en-US'
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
it('should render nothing when the node input variable is not configured', () => {
|
||||
const { container } = render(
|
||||
<Node
|
||||
id="doc-node"
|
||||
data={createData({
|
||||
variable_selector: [],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render the selected input variable on the node', () => {
|
||||
render(
|
||||
<Node
|
||||
id="doc-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should wire panel input changes and format supported file types for english locales', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleVarChanges = vi.fn()
|
||||
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
inputs: createData({
|
||||
is_array_file: false,
|
||||
}),
|
||||
handleVarChanges,
|
||||
}))
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="doc-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'pick-file-var' }))
|
||||
|
||||
expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files'])
|
||||
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.example.com/document-extractor',
|
||||
)
|
||||
expect(screen.getByText('text:string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use chinese separators and array output types when the input is an array of files', () => {
|
||||
mockLocale = LanguagesSupported[1]
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
inputs: createData({
|
||||
is_array_file: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="doc-node"
|
||||
data={createData({
|
||||
is_array_file: true,
|
||||
})}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}')).toBeInTheDocument()
|
||||
expect(screen.getByText('text:array[string]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,705 @@
|
||||
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
|
||||
import type { KeyValue as HttpKeyValue, HttpNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import ApiInput from '../components/api-input'
|
||||
import AuthorizationModal from '../components/authorization'
|
||||
import RadioGroup from '../components/authorization/radio-group'
|
||||
import EditBody from '../components/edit-body'
|
||||
import KeyValue from '../components/key-value'
|
||||
import BulkEdit from '../components/key-value/bulk-edit'
|
||||
import KeyValueEdit from '../components/key-value/key-value-edit'
|
||||
import InputItem from '../components/key-value/key-value-edit/input-item'
|
||||
import KeyValueItem from '../components/key-value/key-value-edit/item'
|
||||
import Timeout from '../components/timeout'
|
||||
import Node from '../node'
|
||||
import Panel from '../panel'
|
||||
import { AuthorizationType, BodyType, Method } from '../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: vi.fn((_nodeId: string, options?: any) => ({
|
||||
availableVars: [
|
||||
{ variable: ['node-1', 'token'], type: VarType.string },
|
||||
{ variable: ['node-1', 'upload'], type: VarType.file },
|
||||
].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true),
|
||||
availableNodes: [],
|
||||
availableNodesWithParent: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
|
||||
default: ({ value, onChange, placeholder, className, readOnly, onFocusChange }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
readOnly={readOnly}
|
||||
onFocus={() => onFocusChange?.(true)}
|
||||
onBlur={() => onFocusChange?.(false)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
default: ({ children }: any) => <div>{children}</div>,
|
||||
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: () => <div>split</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: ({ onChange, filterVar, onRemove }: any) => (
|
||||
<div>
|
||||
<div>{`file-filter:${String(filterVar?.({ type: VarType.file }))}:${String(filterVar?.({ type: VarType.string }))}`}</div>
|
||||
<button type="button" onClick={() => onChange(['node-1', 'file'])}>pick-file</button>
|
||||
{onRemove && <button type="button" onClick={onRemove}>remove-file</button>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
|
||||
default: ({ value, onChange, title }: any) => (
|
||||
<div>
|
||||
<div>{typeof title === 'string' ? title : 'editor'}</div>
|
||||
<input value={value} onChange={event => onChange(event.target.value)} />
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/text-editor', () => ({
|
||||
default: ({ value, onChange, onBlur, headerRight }: any) => (
|
||||
<div>
|
||||
{headerRight}
|
||||
<textarea value={value} onChange={event => onChange(event.target.value)} onBlur={onBlur} />
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
|
||||
default: ({ value }: any) => <div>{value}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({
|
||||
default: ({ options, onChange, trigger }: any) => (
|
||||
<div>
|
||||
{trigger}
|
||||
{options.map((option: any) => (
|
||||
<button key={option.value} type="button" onClick={() => onChange(option.value)}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/curl-panel', () => ({
|
||||
default: () => <div>curl-panel</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseConfig = vi.mocked(useConfig)
|
||||
const mockUseStore = vi.mocked(useStore)
|
||||
|
||||
const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
|
||||
title: 'HTTP Request',
|
||||
desc: '',
|
||||
type: BlockEnum.HttpRequest,
|
||||
variables: [],
|
||||
method: Method.get,
|
||||
url: 'https://api.example.com',
|
||||
authorization: { type: AuthorizationType.none },
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: [] },
|
||||
timeout: { connect: 5, read: 10, write: 15 },
|
||||
ssl_verify: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const keyValueItem: HttpKeyValue = {
|
||||
id: 'kv-1',
|
||||
key: 'name',
|
||||
value: 'alice',
|
||||
type: 'text',
|
||||
}
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
isDataReady: true,
|
||||
inputs: createData(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
filterVar: vi.fn(() => true),
|
||||
handleMethodChange: vi.fn(),
|
||||
handleUrlChange: vi.fn(),
|
||||
headers: [keyValueItem],
|
||||
setHeaders: vi.fn(),
|
||||
addHeader: vi.fn(),
|
||||
isHeaderKeyValueEdit: false,
|
||||
toggleIsHeaderKeyValueEdit: vi.fn(),
|
||||
params: [keyValueItem],
|
||||
setParams: vi.fn(),
|
||||
addParam: vi.fn(),
|
||||
isParamKeyValueEdit: false,
|
||||
toggleIsParamKeyValueEdit: vi.fn(),
|
||||
setBody: vi.fn(),
|
||||
handleSSLVerifyChange: vi.fn(),
|
||||
isShowAuthorization: true,
|
||||
showAuthorization: vi.fn(),
|
||||
hideAuthorization: vi.fn(),
|
||||
setAuthorization: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
isShowCurlPanel: true,
|
||||
showCurlPanel: vi.fn(),
|
||||
hideCurlPanel: vi.fn(),
|
||||
handleCurlImport: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps: PanelProps = {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
}
|
||||
|
||||
const renderPanel = (data: HttpNodeType = createData()) => (
|
||||
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
|
||||
)
|
||||
|
||||
describe('http path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseStore.mockReturnValue({
|
||||
HttpRequest: {
|
||||
timeout: {
|
||||
max_connect_timeout: 10,
|
||||
max_read_timeout: 600,
|
||||
max_write_timeout: 600,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
// The HTTP path should expose auth, request editing, key-value tables, timeout, and request preview behavior.
|
||||
describe('Path Integration', () => {
|
||||
it('should switch radio-group options', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<RadioGroup
|
||||
options={[
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'apiKey', label: 'API Key' },
|
||||
]}
|
||||
value="none"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('API Key'))
|
||||
expect(onChange).toHaveBeenCalledWith('apiKey')
|
||||
})
|
||||
|
||||
it('should edit authorization settings and save them', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
render(
|
||||
<AuthorizationModal
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'apiKey', config: { type: 'custom', header: 'X-Key', api_key: 'secret' } } as any}
|
||||
onChange={onChange}
|
||||
isShow
|
||||
onHide={onHide}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
|
||||
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
|
||||
fireEvent.change(screen.getByDisplayValue('secret'), { target: { value: 'updated-secret' } })
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bootstrap api key config when auth starts without config', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<AuthorizationModal
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'none' as any }}
|
||||
onChange={onChange}
|
||||
isShow
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api-key',
|
||||
config: expect.objectContaining({
|
||||
type: 'basic',
|
||||
api_key: '',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should create custom header auth config and apply focus styles to the api key input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<AuthorizationModal
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'api-key' as any }}
|
||||
onChange={onChange}
|
||||
isShow
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
fireEvent.change(inputs[0] as HTMLInputElement, { target: { value: 'X-Token' } })
|
||||
fireEvent.focus(inputs[1] as HTMLInputElement)
|
||||
expect(inputs[1]).toHaveClass('border-components-input-border-active')
|
||||
fireEvent.change(inputs[1] as HTMLInputElement, { target: { value: 'secret-token' } })
|
||||
fireEvent.blur(inputs[1] as HTMLInputElement)
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api-key',
|
||||
config: expect.objectContaining({
|
||||
type: 'custom',
|
||||
header: 'X-Token',
|
||||
api_key: 'secret-token',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should update method and url from the api input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onMethodChange = vi.fn()
|
||||
const onUrlChange = vi.fn()
|
||||
render(
|
||||
<ApiInput
|
||||
nodeId="node-1"
|
||||
readonly={false}
|
||||
method={'GET' as any}
|
||||
onMethodChange={onMethodChange}
|
||||
url="https://api.example.com"
|
||||
onUrlChange={onUrlChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('POST'))
|
||||
fireEvent.change(screen.getByDisplayValue('https://api.example.com'), { target: { value: 'https://api.changed.com' } })
|
||||
|
||||
expect(onMethodChange).toHaveBeenCalled()
|
||||
expect(onUrlChange).toHaveBeenCalledWith('https://api.changed.com')
|
||||
})
|
||||
|
||||
it('should hide the method dropdown icon and use an empty placeholder in readonly mode', () => {
|
||||
const { container } = render(
|
||||
<ApiInput
|
||||
nodeId="node-1"
|
||||
readonly
|
||||
method={'GET' as any}
|
||||
onMethodChange={vi.fn()}
|
||||
url="https://api.example.com"
|
||||
onUrlChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).toBeNull()
|
||||
expect(screen.getByDisplayValue('https://api.example.com')).toHaveAttribute('placeholder', '')
|
||||
})
|
||||
|
||||
it('should update focus styling for editable inputs and show the remove action again on blur', () => {
|
||||
const onChange = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const { container, rerender } = render(
|
||||
<InputItem
|
||||
nodeId="node-1"
|
||||
value="alice"
|
||||
onChange={onChange}
|
||||
hasRemove
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('alice')
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveClass('bg-components-input-bg-active')
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
fireEvent.blur(input)
|
||||
expect(container.querySelector('button')).not.toBeNull()
|
||||
|
||||
rerender(
|
||||
<InputItem
|
||||
nodeId="node-1"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
hasRemove={false}
|
||||
placeholder="missing-value"
|
||||
readOnly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('missing-value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clamp timeout values and propagate changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<Timeout
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ connect: 5, read: 10, write: 15 }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
|
||||
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '999' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear timeout values to undefined and clamp low values to the minimum', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<Timeout
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ connect: 5, read: 10, write: 15 }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
|
||||
fireEvent.change(screen.getByDisplayValue('10'), { target: { value: '' } })
|
||||
fireEvent.change(screen.getByDisplayValue('15'), { target: { value: '0' } })
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ read: undefined }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ write: 1 }))
|
||||
})
|
||||
|
||||
it('should delegate key-value list editing and bulk editing actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onAdd = vi.fn()
|
||||
|
||||
render(
|
||||
<div>
|
||||
<KeyValue
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[keyValueItem]}
|
||||
onChange={onChange}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
<BulkEdit
|
||||
value="name:alice"
|
||||
onChange={onChange}
|
||||
onSwitchToKeyValueEdit={onAdd}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getAllByDisplayValue('name:alice')[0], { target: { value: 'name:bob' } })
|
||||
fireEvent.blur(screen.getAllByDisplayValue('name:bob')[0])
|
||||
await user.click(screen.getByText('workflow.nodes.http.keyValueEdit'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
expect(onAdd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return null when key-value edit receives a non-array list', () => {
|
||||
const { container } = render(
|
||||
<KeyValueEdit
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={'invalid' as any}
|
||||
onChange={vi.fn()}
|
||||
onAdd={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should edit standalone input items and key-value rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const onAdd = vi.fn()
|
||||
render(
|
||||
<div>
|
||||
<InputItem
|
||||
nodeId="node-1"
|
||||
value="alice"
|
||||
onChange={onChange}
|
||||
hasRemove
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
<KeyValueItem
|
||||
instanceId="kv-1"
|
||||
nodeId="node-1"
|
||||
readonly={false}
|
||||
canRemove
|
||||
payload={keyValueItem}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
isLastItem
|
||||
onAdd={onAdd}
|
||||
isSupportFile
|
||||
/>
|
||||
<KeyValueEdit
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[keyValueItem]}
|
||||
onChange={onChange}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getAllByDisplayValue('alice')[0], { target: { value: 'bob' } })
|
||||
await user.click(screen.getByText('text'))
|
||||
await user.click(screen.getByText('file'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should edit key-only rows and select file payload rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
render(
|
||||
<KeyValueItem
|
||||
instanceId="kv-2"
|
||||
nodeId="node-1"
|
||||
readonly={false}
|
||||
canRemove
|
||||
payload={{ id: 'kv-2', key: 'attachment', value: '', type: 'file', file: [] } as any}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
isLastItem={false}
|
||||
onAdd={vi.fn()}
|
||||
isSupportFile
|
||||
keyNotSupportVar
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('attachment'), { target: { value: 'upload' } })
|
||||
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('pick-file'))
|
||||
await user.click(screen.getByText('remove-file'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update the raw-text body payload', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'raw-text', data: [{ id: 'body-1', type: 'text', value: 'hello' }] } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated-body' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize an empty json body and support legacy string payload rendering', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'json', data: [] } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '{"a":1}' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'json',
|
||||
data: [expect.objectContaining({ value: '{"a":1}' })],
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'json', data: 'legacy' } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should switch to key-value body types and propagate key-value edits', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'none', data: [] } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'form-data' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'form-data',
|
||||
data: [expect.objectContaining({ key: '', value: '' })],
|
||||
}))
|
||||
|
||||
onChange.mockClear()
|
||||
|
||||
render(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'form-data', data: [{ id: 'body-1', type: 'text', key: 'name', value: 'alice' }] } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByDisplayValue('alice')[0]!)
|
||||
fireEvent.change(screen.getAllByDisplayValue('alice')[0]!, { target: { value: 'bob' } })
|
||||
|
||||
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data.length === 2)).toBe(true)
|
||||
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data[0]?.value === 'bob')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render the binary body picker and forward file selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'binary', data: [{ id: 'body-1', type: 'file', file: [] }] } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('pick-file'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize an empty binary body before saving the selected file', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<EditBody
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
payload={{ type: 'binary', data: [] } as any}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('pick-file'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'binary',
|
||||
data: [expect.objectContaining({
|
||||
type: 'file',
|
||||
file: ['node-1', 'file'],
|
||||
})],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render the request node preview when a url exists', () => {
|
||||
renderWorkflowFlowComponent(
|
||||
<Node
|
||||
id="node-1"
|
||||
data={createData()}
|
||||
/>,
|
||||
{ nodes: [], edges: [] },
|
||||
)
|
||||
|
||||
expect(screen.getByText(Method.get)).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when the request url is empty', () => {
|
||||
renderWorkflowFlowComponent(
|
||||
<Node
|
||||
id="node-1"
|
||||
data={createData({ url: '' })}
|
||||
/>,
|
||||
{ nodes: [], edges: [] },
|
||||
)
|
||||
|
||||
expect(screen.queryByText(Method.get)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('https://api.example.com')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the panel sections and output vars', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('body:string')).toBeInTheDocument()
|
||||
expect(screen.getByText('status_code:number')).toBeInTheDocument()
|
||||
expect(screen.getByText('headers:object')).toBeInTheDocument()
|
||||
expect(screen.getByText('files:Array[File]')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('workflow.nodes.http.authorization.authorization').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('workflow.nodes.http.curl.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('curl-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide modal overlays when the panel is readonly', () => {
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
readOnly: true,
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.queryByText('curl-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.http.authorization.api-key-title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CurlPanel from '../curl-panel'
|
||||
import { parseCurl } from '../curl-parser'
|
||||
|
||||
const {
|
||||
mockHandleNodeSelect,
|
||||
mockNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleNodeSelect: vi.fn(),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mockNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('curl-panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('parseCurl', () => {
|
||||
it('should parse method, headers, json body, and query params from a valid curl command', () => {
|
||||
const { node, error } = parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2')
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(node).toMatchObject({
|
||||
method: 'post',
|
||||
url: 'https://example.com/users',
|
||||
headers: 'Authorization: Bearer token',
|
||||
params: 'page: 1\nsize: 2',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error for invalid curl input', () => {
|
||||
expect(parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('component actions', () => {
|
||||
it('should import a parsed curl node and reselect the node after saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onHide = vi.fn()
|
||||
const handleCurlImport = vi.fn()
|
||||
|
||||
render(
|
||||
<CurlPanel
|
||||
nodeId="node-1"
|
||||
isShow
|
||||
onHide={onHide}
|
||||
handleCurlImport={handleCurlImport}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'curl https://example.com')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: 'get',
|
||||
url: 'https://example.com',
|
||||
}))
|
||||
expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true)
|
||||
})
|
||||
|
||||
it('should notify the user when the curl command is invalid', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<CurlPanel
|
||||
nodeId="node-1"
|
||||
isShow
|
||||
onHide={vi.fn()}
|
||||
handleCurlImport={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'invalid')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks'
|
||||
import { BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
import { parseCurl } from './curl-parser'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
@@ -18,104 +18,6 @@ type Props = {
|
||||
handleCurlImport: (node: HttpNodeType) => void
|
||||
}
|
||||
|
||||
const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
|
||||
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
|
||||
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
|
||||
|
||||
const node: Partial<HttpNodeType> = {
|
||||
title: 'HTTP Request',
|
||||
desc: 'Imported from cURL',
|
||||
method: undefined,
|
||||
url: '',
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: '' },
|
||||
}
|
||||
const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
|
||||
let hasData = false
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i].replace(/^['"]|['"]$/g, '')
|
||||
switch (arg) {
|
||||
case '-X':
|
||||
case '--request':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing HTTP method after -X or --request.' }
|
||||
node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get
|
||||
hasData = true
|
||||
break
|
||||
case '-H':
|
||||
case '--header':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing header value after -H or --header.' }
|
||||
node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '')
|
||||
break
|
||||
case '-d':
|
||||
case '--data':
|
||||
case '--data-raw':
|
||||
case '--data-binary': {
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' }
|
||||
const bodyPayload = [{
|
||||
type: BodyPayloadValueType.text,
|
||||
value: args[++i].replace(/^['"]|['"]$/g, ''),
|
||||
}]
|
||||
node.body = { type: BodyType.rawText, data: bodyPayload }
|
||||
break
|
||||
}
|
||||
case '-F':
|
||||
case '--form': {
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing form data after -F or --form.' }
|
||||
if (node.body?.type !== BodyType.formData)
|
||||
node.body = { type: BodyType.formData, data: '' }
|
||||
const formData = args[++i].replace(/^['"]|['"]$/g, '')
|
||||
const [key, ...valueParts] = formData.split('=')
|
||||
if (!key)
|
||||
return { node: null, error: 'Invalid form data format.' }
|
||||
let value = valueParts.join('=')
|
||||
|
||||
// To support command like `curl -F "file=@/path/to/file;type=application/zip"`
|
||||
// the `;type=application/zip` should translate to `Content-Type: application/zip`
|
||||
const typeRegex = /^(.+?);type=(.+)$/
|
||||
const typeMatch = typeRegex.exec(value)
|
||||
if (typeMatch) {
|
||||
const [, actualValue, mimeType] = typeMatch
|
||||
value = actualValue
|
||||
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
|
||||
}
|
||||
|
||||
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
|
||||
break
|
||||
}
|
||||
case '--json':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing JSON data after --json.' }
|
||||
node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') }
|
||||
break
|
||||
default:
|
||||
if (arg.startsWith('http') && !node.url)
|
||||
node.url = arg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final method
|
||||
node.method = node.method || (hasData ? Method.post : Method.get)
|
||||
|
||||
if (!node.url)
|
||||
return { node: null, error: 'Missing URL or url not start with http.' }
|
||||
|
||||
// Extract query params from URL
|
||||
const urlParts = node.url?.split('?') || []
|
||||
if (urlParts.length > 1) {
|
||||
node.url = urlParts[0]
|
||||
node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ')
|
||||
}
|
||||
|
||||
return { node: node as HttpNodeType, error: null }
|
||||
}
|
||||
|
||||
const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
|
||||
const [inputString, setInputString] = useState('')
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user