mirror of
https://github.com/langgenius/dify.git
synced 2026-03-29 01:49:57 +08:00
Merge branch 'feat/model-plugins-implementing' into deploy/dev
This commit is contained in:
5
.github/workflows/main-ci.yml
vendored
5
.github/workflows/main-ci.yml
vendored
@ -63,8 +63,9 @@ jobs:
|
||||
if: needs.check-changes.outputs.web-changed == 'true'
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
with:
|
||||
base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
base_sha: ${{ github.event.before || github.event.pull_request.base.sha }}
|
||||
diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }}
|
||||
head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
|
||||
4
.github/workflows/web-tests.yml
vendored
4
.github/workflows/web-tests.yml
vendored
@ -6,6 +6,9 @@ on:
|
||||
base_sha:
|
||||
required: false
|
||||
type: string
|
||||
diff_range_mode:
|
||||
required: false
|
||||
type: string
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
@ -89,6 +92,7 @@ jobs:
|
||||
- name: Check app/components diff coverage
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
run: node ./scripts/check-components-diff-coverage.mjs
|
||||
|
||||
|
||||
@ -737,24 +737,25 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
|
||||
|
||||
|
||||
# Redis URL used for PubSub between API and
|
||||
# Redis URL used for event bus between API and
|
||||
# celery worker
|
||||
# defaults to url constructed from `REDIS_*`
|
||||
# configurations
|
||||
PUBSUB_REDIS_URL=
|
||||
# Pub/sub channel type for streaming events.
|
||||
# valid options are:
|
||||
EVENT_BUS_REDIS_URL=
|
||||
# Event transport type. Options are:
|
||||
#
|
||||
# - pubsub: for normal Pub/Sub
|
||||
# - sharded: for sharded Pub/Sub
|
||||
# - pubsub: normal Pub/Sub (at-most-once)
|
||||
# - sharded: sharded Pub/Sub (at-most-once)
|
||||
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
|
||||
#
|
||||
# It's highly recommended to use sharded Pub/Sub AND redis cluster
|
||||
# for large deployments.
|
||||
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while running
|
||||
# PubSub.
|
||||
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
|
||||
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
|
||||
# the risk of data loss from Redis auto-eviction under memory pressure.
|
||||
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while use redis as event bus.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
PUBSUB_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
|
||||
)
|
||||
|
||||
PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
|
||||
validation_alias=AliasChoices("EVENT_BUS_REDIS_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
|
||||
validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
|
||||
description=(
|
||||
"Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. "
|
||||
"Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS."
|
||||
"Also accepts ENV: EVENT_BUS_REDIS_USE_CLUSTERS."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@ -55,15 +55,31 @@ def build_protected_resource_metadata_discovery_urls(
|
||||
"""
|
||||
urls = []
|
||||
|
||||
parsed_server_url = urlparse(server_url)
|
||||
base_url = f"{parsed_server_url.scheme}://{parsed_server_url.netloc}"
|
||||
path = parsed_server_url.path.rstrip("/")
|
||||
|
||||
# First priority: URL from WWW-Authenticate header
|
||||
if www_auth_resource_metadata_url:
|
||||
urls.append(www_auth_resource_metadata_url)
|
||||
parsed_metadata_url = urlparse(www_auth_resource_metadata_url)
|
||||
normalized_metadata_url = None
|
||||
if parsed_metadata_url.scheme and parsed_metadata_url.netloc:
|
||||
normalized_metadata_url = www_auth_resource_metadata_url
|
||||
elif not parsed_metadata_url.scheme and parsed_metadata_url.netloc:
|
||||
normalized_metadata_url = f"{parsed_server_url.scheme}:{www_auth_resource_metadata_url}"
|
||||
elif (
|
||||
not parsed_metadata_url.scheme
|
||||
and not parsed_metadata_url.netloc
|
||||
and parsed_metadata_url.path.startswith("/")
|
||||
):
|
||||
first_segment = parsed_metadata_url.path.lstrip("/").split("/", 1)[0]
|
||||
if first_segment == ".well-known" or "." not in first_segment:
|
||||
normalized_metadata_url = urljoin(base_url, parsed_metadata_url.path)
|
||||
|
||||
if normalized_metadata_url:
|
||||
urls.append(normalized_metadata_url)
|
||||
|
||||
# Fallback: construct from server URL
|
||||
parsed = urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
# Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp)
|
||||
if path:
|
||||
path_url = f"{base_url}/.well-known/oauth-protected-resource{path}"
|
||||
|
||||
@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Any, ClassVar, Literal, Self
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from dify_graph.entities.base_node_data import BaseNodeData
|
||||
@ -58,6 +60,39 @@ class EmailDeliveryConfig(BaseModel):
|
||||
"""Configuration for email delivery method."""
|
||||
|
||||
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
|
||||
_SUBJECT_NEWLINE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[\r\n]+")
|
||||
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
|
||||
"a",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"strong",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"ul",
|
||||
]
|
||||
_ALLOWED_HTML_ATTRIBUTES: ClassVar[dict[str, list[str]]] = {
|
||||
"a": ["href", "title"],
|
||||
"td": ["align"],
|
||||
"th": ["align"],
|
||||
}
|
||||
_ALLOWED_PROTOCOLS: ClassVar[list[str]] = ["http", "https", "mailto"]
|
||||
|
||||
recipients: EmailRecipients
|
||||
|
||||
@ -98,6 +133,43 @@ class EmailDeliveryConfig(BaseModel):
|
||||
return templated_body
|
||||
return variable_pool.convert_template(templated_body).text
|
||||
|
||||
@classmethod
|
||||
def render_markdown_body(cls, body: str) -> str:
|
||||
"""Render markdown to safe HTML for email delivery."""
|
||||
sanitized_markdown = bleach.clean(
|
||||
body,
|
||||
tags=[],
|
||||
attributes={},
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
rendered_html = markdown.markdown(
|
||||
sanitized_markdown,
|
||||
extensions=["nl2br", "tables"],
|
||||
extension_configs={"tables": {"use_align_attribute": True}},
|
||||
)
|
||||
return bleach.clean(
|
||||
rendered_html,
|
||||
tags=cls._ALLOWED_HTML_TAGS,
|
||||
attributes=cls._ALLOWED_HTML_ATTRIBUTES,
|
||||
protocols=cls._ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sanitize_subject(cls, subject: str) -> str:
|
||||
"""Sanitize email subject to plain text and prevent CRLF injection."""
|
||||
sanitized_subject = bleach.clean(
|
||||
subject,
|
||||
tags=[],
|
||||
attributes={},
|
||||
strip=True,
|
||||
strip_comments=True,
|
||||
)
|
||||
sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject)
|
||||
return " ".join(sanitized_subject.split())
|
||||
|
||||
|
||||
class _DeliveryMethodBase(BaseModel):
|
||||
"""Base delivery method configuration."""
|
||||
|
||||
@ -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.2", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
@ -91,6 +91,7 @@ dependencies = [
|
||||
"apscheduler>=3.11.0",
|
||||
"weave>=0.52.16",
|
||||
"fastopenapi[flask]>=0.7.0",
|
||||
"bleach~=6.2.0",
|
||||
]
|
||||
# Before adding new dependency, consider place it in
|
||||
# alphabet order (a-z) and suitable group.
|
||||
@ -251,10 +252,7 @@ ignore_errors = true
|
||||
|
||||
[tool.pyrefly]
|
||||
project-includes = ["."]
|
||||
project-excludes = [
|
||||
".venv",
|
||||
"migrations/",
|
||||
]
|
||||
project-excludes = [".venv", "migrations/"]
|
||||
python-platform = "linux"
|
||||
python-version = "3.11.0"
|
||||
infer-with-first-use = false
|
||||
|
||||
@ -155,13 +155,15 @@ class EmailDeliveryTestHandler:
|
||||
context=context,
|
||||
recipient_email=recipient_email,
|
||||
)
|
||||
subject = render_email_template(method.config.subject, substitutions)
|
||||
subject_template = render_email_template(method.config.subject, substitutions)
|
||||
subject = EmailDeliveryConfig.sanitize_subject(subject_template)
|
||||
templated_body = EmailDeliveryConfig.render_body_template(
|
||||
body=method.config.body,
|
||||
url=substitutions.get("form_link"),
|
||||
variable_pool=context.variable_pool,
|
||||
)
|
||||
body = render_email_template(templated_body, substitutions)
|
||||
body = EmailDeliveryConfig.render_markdown_body(body)
|
||||
|
||||
mail.send(
|
||||
to=recipient_email,
|
||||
|
||||
@ -111,7 +111,7 @@ def _render_body(
|
||||
url=form_link,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
return body
|
||||
return EmailDeliveryConfig.render_markdown_body(body)
|
||||
|
||||
|
||||
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
|
||||
@ -173,10 +173,11 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
|
||||
for recipient in job.recipients:
|
||||
form_link = _build_form_link(recipient.token)
|
||||
body = _render_body(job.body, form_link, variable_pool=variable_pool)
|
||||
subject = EmailDeliveryConfig.sanitize_subject(job.subject)
|
||||
|
||||
mail.send(
|
||||
to=recipient.email,
|
||||
subject=job.subject,
|
||||
subject=subject,
|
||||
html=body,
|
||||
)
|
||||
|
||||
|
||||
@ -186,7 +186,7 @@ class DifyTestContainers:
|
||||
# Start Dify Plugin Daemon container for plugin management
|
||||
# Dify Plugin Daemon provides plugin lifecycle management and execution
|
||||
logger.info("Initializing Dify Plugin Daemon container...")
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network(
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
|
||||
self.network
|
||||
)
|
||||
self.dify_plugin_daemon.with_exposed_ports(5002)
|
||||
|
||||
@ -801,6 +801,27 @@ class TestAuthOrchestration:
|
||||
urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com")
|
||||
assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"]
|
||||
|
||||
def test_build_protected_resource_metadata_discovery_urls_with_relative_hint(self):
|
||||
urls = build_protected_resource_metadata_discovery_urls(
|
||||
"/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://api.example.com/tenant/mcp",
|
||||
)
|
||||
assert urls == [
|
||||
"https://api.example.com/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://api.example.com/.well-known/oauth-protected-resource",
|
||||
]
|
||||
|
||||
def test_build_protected_resource_metadata_discovery_urls_ignores_scheme_less_hint(self):
|
||||
urls = build_protected_resource_metadata_discovery_urls(
|
||||
"/openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/tenant/mcp",
|
||||
)
|
||||
|
||||
assert urls == [
|
||||
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
|
||||
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource",
|
||||
]
|
||||
|
||||
def test_build_oauth_authorization_server_metadata_discovery_urls(self):
|
||||
# Case 1: with auth_server_url
|
||||
urls = build_oauth_authorization_server_metadata_discovery_urls(
|
||||
|
||||
@ -14,3 +14,64 @@ def test_render_body_template_replaces_variable_values():
|
||||
result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
|
||||
|
||||
assert result == "Hello World https://example.com"
|
||||
|
||||
|
||||
def test_render_markdown_body_renders_markdown_to_html():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)")
|
||||
|
||||
assert "<strong>Bold</strong>" in rendered
|
||||
assert '<a href="https://example.com">link</a>' in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_sanitizes_unsafe_html():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body(
|
||||
'<script>alert("xss")</script><a href="javascript:alert(1)" onclick="alert(2)">Click</a>'
|
||||
)
|
||||
|
||||
assert "<script" not in rendered
|
||||
assert "<a" not in rendered
|
||||
assert "onclick" not in rendered
|
||||
assert "javascript:" not in rendered
|
||||
assert "Click" in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_sanitizes_markdown_link_with_javascript_href():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("[bad](javascript:alert(1)) and [ok](https://example.com)")
|
||||
|
||||
assert "javascript:" not in rendered
|
||||
assert "<a>bad</a>" in rendered
|
||||
assert '<a href="https://example.com">ok</a>' in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_does_not_allow_raw_html_tags():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("<b>raw html</b> and **markdown**")
|
||||
|
||||
assert "<b>" not in rendered
|
||||
assert "raw html" in rendered
|
||||
assert "<strong>markdown</strong>" in rendered
|
||||
|
||||
|
||||
def test_render_markdown_body_supports_table_syntax():
|
||||
rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |")
|
||||
|
||||
assert "<table>" in rendered
|
||||
assert "<thead>" in rendered
|
||||
assert "<tbody>" in rendered
|
||||
assert 'align="right"' in rendered
|
||||
assert "style=" not in rendered
|
||||
|
||||
|
||||
def test_sanitize_subject_removes_crlf():
|
||||
sanitized = EmailDeliveryConfig.sanitize_subject("Notice\r\nBCC:attacker@example.com")
|
||||
|
||||
assert "\r" not in sanitized
|
||||
assert "\n" not in sanitized
|
||||
assert sanitized == "Notice BCC:attacker@example.com"
|
||||
|
||||
|
||||
def test_sanitize_subject_removes_html_tags():
|
||||
sanitized = EmailDeliveryConfig.sanitize_subject("<b>Alert</b><img src=x onerror=1>")
|
||||
|
||||
assert "<" not in sanitized
|
||||
assert ">" not in sanitized
|
||||
assert sanitized == "Alert"
|
||||
|
||||
@ -207,6 +207,45 @@ class TestEmailDeliveryTestHandler:
|
||||
assert kwargs["to"] == "test@example.com"
|
||||
assert "RENDERED_Subj" in kwargs["subject"]
|
||||
|
||||
def test_send_test_sanitizes_subject(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
|
||||
mock_mail_send = MagicMock()
|
||||
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"render_email_template",
|
||||
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
|
||||
)
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
|
||||
|
||||
context = DeliveryTestContext(
|
||||
tenant_id="t1",
|
||||
app_id="a1",
|
||||
node_id="n1",
|
||||
node_title="title",
|
||||
rendered_content="content",
|
||||
recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")],
|
||||
)
|
||||
method = EmailDeliveryMethod(
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(whole_workspace=False, items=[]),
|
||||
subject="<b>Notice</b>\r\nBCC:{{ recipient_email }}",
|
||||
body="Body",
|
||||
)
|
||||
)
|
||||
|
||||
handler.send_test(context=context, method=method)
|
||||
|
||||
_, kwargs = mock_mail_send.call_args
|
||||
assert kwargs["subject"] == "Notice BCC:test@example.com"
|
||||
|
||||
def test_resolve_recipients(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
|
||||
|
||||
@ -120,4 +120,37 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert mail.sent[0]["html"] == "Body OK"
|
||||
assert mail.sent[0]["html"] == "<p>Body OK</p>"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"])
|
||||
def test_dispatch_human_input_email_task_sanitizes_subject(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
line_break: str,
|
||||
):
|
||||
mail = _DummyMail()
|
||||
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
|
||||
job = task_module._EmailDeliveryJob(
|
||||
form_id="form-1",
|
||||
subject=f"Notice{line_break}BCC:attacker@example.com <b>Alert</b>",
|
||||
body="Body",
|
||||
form_content="content",
|
||||
recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(task_module, "mail", mail)
|
||||
monkeypatch.setattr(
|
||||
task_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job])
|
||||
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None)
|
||||
|
||||
task_module.dispatch_human_input_email_task(
|
||||
form_id="form-1",
|
||||
node_title="Approve",
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert"
|
||||
|
||||
14
api/uv.lock
generated
14
api/uv.lock
generated
@ -658,6 +658,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cc/38b6f87170908bd8aaf9e412b021d17e85f690abe00edf50192f1a4566b9/billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b", size = 87042, upload-time = "2025-11-16T17:47:29.005Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bleach"
|
||||
version = "6.2.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" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
@ -1529,6 +1541,7 @@ dependencies = [
|
||||
{ name = "arize-phoenix-otel" },
|
||||
{ name = "azure-identity" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "bleach" },
|
||||
{ name = "boto3" },
|
||||
{ name = "bs4" },
|
||||
{ name = "cachetools" },
|
||||
@ -1730,6 +1743,7 @@ 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 = "bs4", specifier = "~=0.0.1" },
|
||||
{ name = "cachetools", specifier = "~=5.3.0" },
|
||||
|
||||
@ -1546,24 +1546,25 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
||||
|
||||
# Redis URL used for PubSub between API and
|
||||
# Redis URL used for event bus between API and
|
||||
# celery worker
|
||||
# defaults to url constructed from `REDIS_*`
|
||||
# configurations
|
||||
PUBSUB_REDIS_URL=
|
||||
# Pub/sub channel type for streaming events.
|
||||
# valid options are:
|
||||
EVENT_BUS_REDIS_URL=
|
||||
# Event transport type. Options are:
|
||||
#
|
||||
# - pubsub: for normal Pub/Sub
|
||||
# - sharded: for sharded Pub/Sub
|
||||
# - pubsub: normal Pub/Sub (at-most-once)
|
||||
# - sharded: sharded Pub/Sub (at-most-once)
|
||||
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
|
||||
#
|
||||
# It's highly recommended to use sharded Pub/Sub AND redis cluster
|
||||
# for large deployments.
|
||||
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while running
|
||||
# PubSub.
|
||||
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
|
||||
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
|
||||
# the risk of data loss from Redis auto-eviction under memory pressure.
|
||||
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
||||
# Whether to use Redis cluster mode while use redis as event bus.
|
||||
# It's highly recommended to enable this for large deployments.
|
||||
PUBSUB_REDIS_USE_CLUSTERS=false
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||
|
||||
# Whether to Enable human input timeout check task
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
|
||||
@ -269,7 +269,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@ -123,7 +123,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@ -699,9 +699,9 @@ x-shared-env: &shared-api-worker-env
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
|
||||
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
|
||||
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
|
||||
PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false}
|
||||
EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-}
|
||||
EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub}
|
||||
EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false}
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true}
|
||||
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000}
|
||||
@ -976,7 +976,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
buildGitDiffRevisionArgs,
|
||||
getChangedBranchCoverage,
|
||||
getChangedStatementCoverage,
|
||||
getIgnoredChangedLinesFromSource,
|
||||
@ -7,6 +8,11 @@ import {
|
||||
} from '../scripts/check-components-diff-coverage-lib.mjs'
|
||||
|
||||
describe('check-components-diff-coverage helpers', () => {
|
||||
it('should build exact and merge-base git diff revision args', () => {
|
||||
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha'])
|
||||
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha'])
|
||||
})
|
||||
|
||||
it('should parse changed line maps from unified diffs', () => {
|
||||
const diff = [
|
||||
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts',
|
||||
@ -79,6 +85,23 @@ describe('check-components-diff-coverage helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should report the first changed line inside a multi-line uncovered statement', () => {
|
||||
const entry = {
|
||||
s: { 0: 0 },
|
||||
statementMap: {
|
||||
0: { start: { line: 10 }, end: { line: 14 } },
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedStatementCoverage(entry, new Set([13, 14]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 0,
|
||||
total: 1,
|
||||
uncoveredLines: [13],
|
||||
})
|
||||
})
|
||||
|
||||
it('should fail changed lines when a source file has no coverage entry', () => {
|
||||
const coverage = getChangedStatementCoverage(undefined, new Set([42, 43]))
|
||||
|
||||
@ -118,6 +141,36 @@ describe('check-components-diff-coverage helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should report the first changed line inside a multi-line uncovered branch arm', () => {
|
||||
const entry = {
|
||||
b: {
|
||||
0: [0, 0],
|
||||
},
|
||||
branchMap: {
|
||||
0: {
|
||||
line: 30,
|
||||
loc: { start: { line: 30 }, end: { line: 35 } },
|
||||
locations: [
|
||||
{ start: { line: 31 }, end: { line: 34 } },
|
||||
{ start: { line: 35 }, end: { line: 38 } },
|
||||
],
|
||||
type: 'if',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const coverage = getChangedBranchCoverage(entry, new Set([33]))
|
||||
|
||||
expect(coverage).toEqual({
|
||||
covered: 0,
|
||||
total: 2,
|
||||
uncoveredBranches: [
|
||||
{ armIndex: 0, line: 33 },
|
||||
{ armIndex: 1, line: 35 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore changed lines with valid pragma reasons and report invalid pragmas', () => {
|
||||
const sourceCode = [
|
||||
'const a = 1',
|
||||
|
||||
@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
|
||||
})
|
||||
|
||||
render(<ParamConfigContent />)
|
||||
const input = screen.getByRole('spinbutton') as HTMLInputElement
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: '4' } })
|
||||
|
||||
const updatedFile = getLatestFileConfig()
|
||||
|
||||
@ -184,8 +184,8 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
const [topKInput] = dialogScope.getAllByRole('spinbutton')
|
||||
expect(topKInput).toHaveValue(5)
|
||||
const [topKInput] = dialogScope.getAllByRole('textbox')
|
||||
expect(topKInput).toHaveValue('5')
|
||||
})
|
||||
|
||||
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
|
||||
@ -197,10 +197,10 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const reopenedScope = within(reopenedDialog)
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
|
||||
|
||||
// Assert
|
||||
expect(reopenedTopKInput).toHaveValue(5)
|
||||
expect(reopenedTopKInput).toHaveValue('5')
|
||||
})
|
||||
|
||||
it('should discard changes when cancel is clicked', async () => {
|
||||
@ -217,8 +217,8 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
const [topKInput] = dialogScope.getAllByRole('spinbutton')
|
||||
expect(topKInput).toHaveValue(5)
|
||||
const [topKInput] = dialogScope.getAllByRole('textbox')
|
||||
expect(topKInput).toHaveValue('5')
|
||||
})
|
||||
|
||||
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
|
||||
@ -231,10 +231,10 @@ describe('dataset-config/params-config', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const reopenedScope = within(reopenedDialog)
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
|
||||
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
|
||||
|
||||
// Assert
|
||||
expect(reopenedTopKInput).toHaveValue(4)
|
||||
expect(reopenedTopKInput).toHaveValue('4')
|
||||
})
|
||||
|
||||
it('should prevent saving when rerank model is required but invalid', async () => {
|
||||
|
||||
@ -419,6 +419,21 @@ describe('ModelParameterTrigger', () => {
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
|
||||
})
|
||||
|
||||
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
|
||||
const { unmount } = renderComponent()
|
||||
const triggerContent = capturedModalProps?.renderTrigger({
|
||||
open: true,
|
||||
currentProvider: { provider: 'openai' },
|
||||
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
|
||||
})
|
||||
|
||||
unmount()
|
||||
const { container } = render(<>{triggerContent}</>)
|
||||
|
||||
expect(container.firstChild).toHaveClass('bg-state-base-hover')
|
||||
expect(container.firstChild).toHaveClass('!bg-[#FFFAEB]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
|
||||
@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
|
||||
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
|
||||
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
|
||||
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
|
||||
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
|
||||
<Image
|
||||
<img
|
||||
className={show ? '' : 'hidden'}
|
||||
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
|
||||
alt="App Screen Shot"
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { EmbeddedChatbotContextValue } from '../../context'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
@ -22,15 +20,6 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
|
||||
default: () => <div data-testid="view-form-dropdown" />,
|
||||
}))
|
||||
|
||||
// Mock next/image to render a normal img tag for testing
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
|
||||
const { unoptimized: _, ...rest } = props
|
||||
return <img {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CheckboxList from '..'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('checkbox list component', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@ -169,7 +168,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
|
||||
{searchQuery
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Image alt="search menu" src={SearchMenu} width={32} />
|
||||
<img alt="search menu" src={SearchMenu.src} width={32} />
|
||||
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
|
||||
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FileThumb from '../index'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('FileThumb Component', () => {
|
||||
const mockImageFile = {
|
||||
name: 'test-image.jpg',
|
||||
|
||||
@ -22,7 +22,7 @@ describe('NumberInputField', () => {
|
||||
|
||||
it('should render current number value', () => {
|
||||
render(<NumberInputField label="Count" />)
|
||||
expect(screen.getByDisplayValue('2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('2')
|
||||
})
|
||||
|
||||
it('should update value when users click increment', () => {
|
||||
|
||||
@ -45,7 +45,7 @@ describe('BaseField', () => {
|
||||
it('should render a number input when configured as number input', () => {
|
||||
render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('Age')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ describe('InputNumber Component', () => {
|
||||
|
||||
it('renders input with default values', () => {
|
||||
render(<InputNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -60,7 +60,7 @@ describe('InputNumber Component', () => {
|
||||
it('handles direct input changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '42' } })
|
||||
expect(onChange).toHaveBeenCalledWith(42)
|
||||
@ -69,38 +69,25 @@ describe('InputNumber Component', () => {
|
||||
it('handles empty input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={1} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not call onChange when parsed value is NaN', () => {
|
||||
it('does not call onChange when input is not parseable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
const originalNumber = globalThis.Number
|
||||
const numberSpy = vi.spyOn(globalThis, 'Number').mockImplementation((val: unknown) => {
|
||||
if (val === '123') {
|
||||
return Number.NaN
|
||||
}
|
||||
return originalNumber(val)
|
||||
})
|
||||
|
||||
try {
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
numberSpy.mockRestore()
|
||||
}
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onChange when direct input exceeds range', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} min={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '11' } })
|
||||
|
||||
@ -141,7 +128,7 @@ describe('InputNumber Component', () => {
|
||||
it('disables controls when disabled prop is true', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} disabled />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
|
||||
|
||||
@ -211,6 +198,16 @@ describe('InputNumber Component', () => {
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fallback step guard when step is any', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
|
||||
const incrementBtn = screen.getByRole('button', { name: /increment/i })
|
||||
|
||||
await user.click(incrementBtn)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents decrement below min with custom amount', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
@ -244,7 +241,7 @@ describe('InputNumber Component', () => {
|
||||
it('validates input against max constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} max={10} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
@ -253,7 +250,7 @@ describe('InputNumber Component', () => {
|
||||
it('validates input against min constraint', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={5} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '2' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
@ -262,7 +259,7 @@ describe('InputNumber Component', () => {
|
||||
it('accepts input within min and max constraints', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={0} max={100} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '50' } })
|
||||
expect(onChange).toHaveBeenCalledWith(50)
|
||||
@ -296,6 +293,25 @@ describe('InputNumber Component', () => {
|
||||
expect(wrapper).toHaveClass(wrapClassName)
|
||||
})
|
||||
|
||||
it('applies wrapperClassName to outer div for Input compatibility', () => {
|
||||
const onChange = vi.fn()
|
||||
const wrapperClassName = 'custom-input-wrapper'
|
||||
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const wrapper = screen.getByTestId('input-number-wrapper')
|
||||
|
||||
expect(input).not.toHaveAttribute('wrapperClassName')
|
||||
expect(wrapper).toHaveClass(wrapperClassName)
|
||||
})
|
||||
|
||||
it('applies styleCss to the input element', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
|
||||
})
|
||||
|
||||
it('applies controlWrapClassName to control buttons container', () => {
|
||||
const onChange = vi.fn()
|
||||
const controlWrapClassName = 'custom-control-wrap'
|
||||
@ -327,7 +343,7 @@ describe('InputNumber Component', () => {
|
||||
it('handles zero as a valid input', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
import type { FC } from 'react'
|
||||
import type { InputProps } from '../input'
|
||||
import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
|
||||
import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Input from '../input'
|
||||
|
||||
export type InputNumberProps = {
|
||||
type InputNumberInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
|
||||
>
|
||||
|
||||
export type InputNumberProps = InputNumberInputProps & {
|
||||
unit?: string
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
@ -12,19 +25,69 @@ export type InputNumberProps = {
|
||||
size?: 'regular' | 'large'
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number | 'any'
|
||||
defaultValue?: number
|
||||
disabled?: boolean
|
||||
wrapClassName?: string
|
||||
wrapperClassName?: string
|
||||
styleCss?: CSSProperties
|
||||
controlWrapClassName?: string
|
||||
controlClassName?: string
|
||||
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'>
|
||||
type?: 'number'
|
||||
}
|
||||
|
||||
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
|
||||
'increment-press',
|
||||
'decrement-press',
|
||||
])
|
||||
|
||||
const isValueWithinBounds = (value: number, min?: number, max?: number) => {
|
||||
if (typeof min === 'number' && value < min)
|
||||
return false
|
||||
|
||||
if (typeof max === 'number' && value > max)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const resolveStep = (amount?: number, step?: InputNumberProps['step']) => (
|
||||
amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1
|
||||
)
|
||||
|
||||
const exceedsStepBounds = ({
|
||||
value,
|
||||
reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value?: number
|
||||
reason: BaseNumberFieldRoot.ChangeEventDetails['reason']
|
||||
stepAmount: number
|
||||
min?: number
|
||||
max?: number
|
||||
}) => {
|
||||
if (typeof value !== 'number')
|
||||
return false
|
||||
|
||||
if (reason === 'increment-press' && typeof max === 'number')
|
||||
return value + stepAmount > max
|
||||
|
||||
if (reason === 'decrement-press' && typeof min === 'number')
|
||||
return value - stepAmount < min
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
const {
|
||||
unit,
|
||||
className,
|
||||
wrapperClassName,
|
||||
styleCss,
|
||||
onChange,
|
||||
amount = 1,
|
||||
amount,
|
||||
value,
|
||||
size = 'regular',
|
||||
max,
|
||||
@ -34,96 +97,97 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
|
||||
controlWrapClassName,
|
||||
controlClassName,
|
||||
disabled,
|
||||
step,
|
||||
id,
|
||||
name,
|
||||
readOnly,
|
||||
required,
|
||||
type: _type,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const isValidValue = useCallback((v: number) => {
|
||||
if (typeof max === 'number' && v > max)
|
||||
return false
|
||||
return !(typeof min === 'number' && v < min)
|
||||
}, [max, min])
|
||||
const resolvedStep = resolveStep(amount, step)
|
||||
const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
|
||||
|
||||
const inc = () => {
|
||||
/* v8 ignore next 2 - @preserve */
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
if (value === undefined) {
|
||||
const handleValueChange = useCallback((
|
||||
nextValue: number | null,
|
||||
eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
|
||||
) => {
|
||||
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
|
||||
onChange(defaultValue ?? 0)
|
||||
return
|
||||
}
|
||||
const newValue = value + amount
|
||||
if (!isValidValue(newValue))
|
||||
return
|
||||
onChange(newValue)
|
||||
}
|
||||
const dec = () => {
|
||||
/* v8 ignore next 2 - @preserve */
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
if (value === undefined) {
|
||||
onChange(defaultValue ?? 0)
|
||||
return
|
||||
}
|
||||
const newValue = value - amount
|
||||
if (!isValidValue(newValue))
|
||||
return
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value === '') {
|
||||
if (nextValue === null) {
|
||||
onChange(0)
|
||||
return
|
||||
}
|
||||
const parsed = Number(e.target.value)
|
||||
if (Number.isNaN(parsed))
|
||||
|
||||
if (exceedsStepBounds({
|
||||
value,
|
||||
reason: eventDetails.reason,
|
||||
stepAmount,
|
||||
min,
|
||||
max,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValueWithinBounds(nextValue, min, max))
|
||||
return
|
||||
|
||||
if (!isValidValue(parsed))
|
||||
return
|
||||
onChange(parsed)
|
||||
}, [isValidValue, onChange])
|
||||
onChange(nextValue)
|
||||
}, [defaultValue, max, min, onChange, stepAmount, value])
|
||||
|
||||
return (
|
||||
<div data-testid="input-number-wrapper" className={cn('flex', wrapClassName)}>
|
||||
<Input
|
||||
{...rest}
|
||||
// disable default controller
|
||||
type="number"
|
||||
className={cn('rounded-r-none no-spinner', className)}
|
||||
value={value ?? 0}
|
||||
max={max}
|
||||
<div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
|
||||
<NumberField
|
||||
className="min-w-0 grow"
|
||||
value={value ?? null}
|
||||
min={min}
|
||||
max={max}
|
||||
step={resolvedStep}
|
||||
disabled={disabled}
|
||||
onChange={handleInputChange}
|
||||
unit={unit}
|
||||
size={size}
|
||||
/>
|
||||
<div
|
||||
data-testid="input-number-controls"
|
||||
className={cn('flex flex-col rounded-r-md border-l border-divider-subtle bg-components-input-bg-normal text-text-tertiary focus:shadow-xs', disabled && 'cursor-not-allowed opacity-50', controlWrapClassName)}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
id={id}
|
||||
name={name}
|
||||
allowOutOfRange
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={inc}
|
||||
disabled={disabled}
|
||||
aria-label="increment"
|
||||
className={cn(size === 'regular' ? 'pt-1' : 'pt-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
|
||||
>
|
||||
<span className="i-ri-arrow-up-s-line size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dec}
|
||||
disabled={disabled}
|
||||
aria-label="decrement"
|
||||
className={cn(size === 'regular' ? 'pb-1' : 'pb-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
|
||||
>
|
||||
<span className="i-ri-arrow-down-s-line size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<NumberFieldGroup size={size}>
|
||||
<NumberFieldInput
|
||||
{...rest}
|
||||
size={size}
|
||||
style={styleCss}
|
||||
className={className}
|
||||
/>
|
||||
{unit && (
|
||||
<NumberFieldUnit size={size}>
|
||||
{unit}
|
||||
</NumberFieldUnit>
|
||||
)}
|
||||
<NumberFieldControls
|
||||
data-testid="input-number-controls"
|
||||
className={controlWrapClassName}
|
||||
>
|
||||
<NumberFieldIncrement
|
||||
aria-label="increment"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
|
||||
</NumberFieldIncrement>
|
||||
<NumberFieldDecrement
|
||||
aria-label="decrement"
|
||||
size={size}
|
||||
className={controlClassName}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
|
||||
</NumberFieldDecrement>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import WithIconCardItem from './with-icon-card-item'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('WithIconCardItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WithIconCardItemProps } from './markdown-with-directive-schema'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type WithIconItemProps = WithIconCardItemProps & {
|
||||
@ -11,18 +10,13 @@ type WithIconItemProps = WithIconCardItemProps & {
|
||||
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
|
||||
return (
|
||||
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
|
||||
{/*
|
||||
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
|
||||
* https://github.com/vercel/next.js/issues/88873
|
||||
*/}
|
||||
<Image
|
||||
<img
|
||||
src={icon}
|
||||
className="!border-none object-contain"
|
||||
alt={iconAlt ?? ''}
|
||||
aria-hidden={iconAlt ? undefined : true}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
/>
|
||||
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
|
||||
{children}
|
||||
|
||||
@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index'
|
||||
|
||||
const FOUR_COLON_RE = /:{4}/
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
function expectDecorativeIcon(container: HTMLElement, src: string) {
|
||||
const icon = container.querySelector('img')
|
||||
expect(icon).toBeInTheDocument()
|
||||
|
||||
@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CredentialSelector from '../index'
|
||||
|
||||
// Mock CredentialIcon since it's likely a complex component or uses next/image
|
||||
// Mock CredentialIcon since it's likely a complex component.
|
||||
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
|
||||
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
|
||||
}))
|
||||
|
||||
@ -53,7 +53,7 @@ describe('ParamItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -68,7 +68,7 @@ describe('ParamItem', () => {
|
||||
it('should disable InputNumber when enable is false', () => {
|
||||
render(<ParamItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable Slider when enable is false', () => {
|
||||
@ -104,7 +104,7 @@ describe('ParamItem', () => {
|
||||
}
|
||||
|
||||
render(<StatefulParamItem />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '0.8')
|
||||
@ -166,14 +166,10 @@ describe('ParamItem', () => {
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '10')
|
||||
})
|
||||
|
||||
it('should use default step of 0.1 and min of 0 when not provided', () => {
|
||||
it('should expose default minimum of 0 when min is not provided', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
// Component renders without error with default step/min
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('step', '0.1')
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -62,7 +62,7 @@ describe('ScoreThresholdItem', () => {
|
||||
it('should disable controls when enable is false', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@ -70,23 +70,19 @@ describe('ScoreThresholdItem', () => {
|
||||
describe('Value Clamping', () => {
|
||||
it('should clamp values to minimum of 0', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('min', '0')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clamp values to maximum of 1', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('max', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use step of 0.01', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('step', '0.01')
|
||||
render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0.5')
|
||||
})
|
||||
|
||||
it('should call onChange with rounded value when input changes', async () => {
|
||||
@ -107,7 +103,7 @@ describe('ScoreThresholdItem', () => {
|
||||
}
|
||||
|
||||
render(<StatefulScoreThresholdItem />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '0.55')
|
||||
@ -138,8 +134,8 @@ describe('ScoreThresholdItem', () => {
|
||||
|
||||
it('should clamp to max=1 when value exceeds maximum', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} value={1.5} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(1)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -36,7 +36,7 @@ describe('TopKItem', () => {
|
||||
it('should render InputNumber and Slider', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -51,7 +51,7 @@ describe('TopKItem', () => {
|
||||
it('should disable controls when enable is false', () => {
|
||||
render(<TopKItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeDisabled()
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@ -59,23 +59,20 @@ describe('TopKItem', () => {
|
||||
describe('Value Limits', () => {
|
||||
it('should use step of 1', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('step', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('2')
|
||||
})
|
||||
|
||||
it('should use minimum of 1', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use maximum from env (10)', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveAttribute('max', '10')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render slider with max >= 5 so no scaling is applied', () => {
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
|
||||
|
||||
let mockModelProviders: Array<{ provider: string }> = []
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: <T>(selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T =>
|
||||
selector({ modelProviders: mockModelProviders }),
|
||||
}))
|
||||
|
||||
const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap =>
|
||||
({
|
||||
target: {
|
||||
title: 'Target',
|
||||
type: BlockEnum.Start,
|
||||
...node,
|
||||
},
|
||||
} as unknown as WorkflowNodesMap)
|
||||
|
||||
describe('useLlmModelPluginInstalled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockModelProviders = []
|
||||
})
|
||||
|
||||
it('should return true when the node is missing', () => {
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', undefined))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when the node is not an LLM node', () => {
|
||||
const workflowNodesMap = createWorkflowNodesMap({
|
||||
id: 'target',
|
||||
type: BlockEnum.Start,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when the matching model plugin is installed', () => {
|
||||
mockModelProviders = [
|
||||
{ provider: 'langgenius/openai/openai' },
|
||||
{ provider: 'langgenius/anthropic/claude' },
|
||||
]
|
||||
const workflowNodesMap = createWorkflowNodesMap({
|
||||
id: 'target',
|
||||
type: BlockEnum.LLM,
|
||||
modelProvider: 'langgenius/openai/gpt-4.1',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when the matching model plugin is not installed', () => {
|
||||
mockModelProviders = [
|
||||
{ provider: 'langgenius/anthropic/claude' },
|
||||
]
|
||||
const workflowNodesMap = createWorkflowNodesMap({
|
||||
id: 'target',
|
||||
type: BlockEnum.LLM,
|
||||
modelProvider: 'langgenius/openai/gpt-4.1',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
57
web/app/components/base/tag-input/__tests__/interop.spec.tsx
Normal file
57
web/app/components/base/tag-input/__tests__/interop.spec.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import type { ComponentType, InputHTMLAttributes } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
type AutosizeInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
const MockAutosizeInput: ComponentType<AutosizeInputProps> = ({ inputClassName, ...props }) => (
|
||||
<input data-testid="autosize-input" className={inputClassName} {...props} />
|
||||
)
|
||||
|
||||
describe('TagInput autosize interop', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should support a namespace-style default export from react-18-input-autosize', async () => {
|
||||
vi.doMock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
vi.doMock('react-18-input-autosize', () => ({
|
||||
default: {
|
||||
default: MockAutosizeInput,
|
||||
},
|
||||
}))
|
||||
|
||||
const { default: TagInput } = await import('../index')
|
||||
|
||||
render(<TagInput items={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support a direct default export from react-18-input-autosize', async () => {
|
||||
vi.doMock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
vi.doMock('react-18-input-autosize', () => ({
|
||||
default: MockAutosizeInput,
|
||||
}))
|
||||
|
||||
const { default: TagInput } = await import('../index')
|
||||
|
||||
render(<TagInput items={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
113
web/app/components/base/ui/number-field/__tests__/index.spec.tsx
Normal file
113
web/app/components/base/ui/number-field/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '../index'
|
||||
|
||||
describe('NumberField wrapper', () => {
|
||||
describe('Exports', () => {
|
||||
it('should map NumberField to the matching base primitive root', () => {
|
||||
expect(NumberField).toBe(BaseNumberField.Root)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variants', () => {
|
||||
it('should apply regular variant classes and forward className to group and input', () => {
|
||||
render(
|
||||
<NumberField defaultValue={12}>
|
||||
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
|
||||
<NumberFieldInput
|
||||
aria-label="Regular amount"
|
||||
placeholder="Regular placeholder"
|
||||
size="regular"
|
||||
className="custom-input"
|
||||
/>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Regular amount' })
|
||||
|
||||
expect(group).toHaveClass('radius-md')
|
||||
expect(group).toHaveClass('custom-group')
|
||||
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
|
||||
expect(input).toHaveClass('px-3')
|
||||
expect(input).toHaveClass('py-[7px]')
|
||||
expect(input).toHaveClass('custom-input')
|
||||
})
|
||||
|
||||
it('should apply large variant classes to grouped parts when large size is provided', () => {
|
||||
render(
|
||||
<NumberField defaultValue={24}>
|
||||
<NumberFieldGroup size="large" data-testid="group">
|
||||
<NumberFieldInput aria-label="Large amount" size="large" />
|
||||
<NumberFieldUnit size="large">ms</NumberFieldUnit>
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement aria-label="Increment amount" size="large" />
|
||||
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('group')
|
||||
const input = screen.getByRole('textbox', { name: 'Large amount' })
|
||||
const unit = screen.getByText('ms')
|
||||
const increment = screen.getByRole('button', { name: 'Increment amount' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
|
||||
|
||||
expect(group).toHaveClass('radius-lg')
|
||||
expect(input).toHaveClass('px-4')
|
||||
expect(input).toHaveClass('py-2')
|
||||
expect(unit).toHaveClass('flex')
|
||||
expect(unit).toHaveClass('items-center')
|
||||
expect(unit).toHaveClass('pr-2.5')
|
||||
expect(increment).toHaveClass('pt-1.5')
|
||||
expect(decrement).toHaveClass('pb-1.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward passthrough props and custom classes to controls and buttons', () => {
|
||||
render(
|
||||
<NumberField defaultValue={8}>
|
||||
<NumberFieldGroup size="regular">
|
||||
<NumberFieldInput aria-label="Amount" size="regular" />
|
||||
<NumberFieldControls className="custom-controls" data-testid="controls">
|
||||
<NumberFieldIncrement
|
||||
aria-label="Increment"
|
||||
size="regular"
|
||||
className="custom-increment"
|
||||
data-track-id="increment-track"
|
||||
/>
|
||||
<NumberFieldDecrement
|
||||
aria-label="Decrement"
|
||||
size="regular"
|
||||
className="custom-decrement"
|
||||
data-track-id="decrement-track"
|
||||
/>
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>,
|
||||
)
|
||||
|
||||
const controls = screen.getByTestId('controls')
|
||||
const increment = screen.getByRole('button', { name: 'Increment' })
|
||||
const decrement = screen.getByRole('button', { name: 'Decrement' })
|
||||
|
||||
expect(controls).toHaveClass('border-l')
|
||||
expect(controls).toHaveClass('custom-controls')
|
||||
expect(increment).toHaveClass('custom-increment')
|
||||
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
|
||||
expect(decrement).toHaveClass('custom-decrement')
|
||||
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
|
||||
})
|
||||
})
|
||||
})
|
||||
211
web/app/components/base/ui/number-field/index.tsx
Normal file
211
web/app/components/base/ui/number-field/index.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const NumberField = BaseNumberField.Root
|
||||
|
||||
export const numberFieldGroupVariants = cva(
|
||||
[
|
||||
'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-none transition-[background-color,border-color,box-shadow]',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled',
|
||||
'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled',
|
||||
'data-[readonly]:shadow-none motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'radius-md',
|
||||
large: 'radius-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
|
||||
|
||||
export function NumberFieldGroup({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldGroupProps) {
|
||||
return (
|
||||
<BaseNumberField.Group
|
||||
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldInputVariants = cva(
|
||||
[
|
||||
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-none',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
|
||||
'data-[readonly]:cursor-default',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'px-3 py-[7px] system-sm-regular',
|
||||
large: 'px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
|
||||
|
||||
export function NumberFieldInput({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldInputProps) {
|
||||
return (
|
||||
<BaseNumberField.Input
|
||||
className={cn(numberFieldInputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldUnitVariants = cva(
|
||||
'flex shrink-0 items-center self-stretch text-text-tertiary system-sm-regular',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: 'pr-2',
|
||||
large: 'pr-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
|
||||
|
||||
export function NumberFieldUnit({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldUnitProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(numberFieldUnitVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldControlsVariants = cva(
|
||||
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
|
||||
)
|
||||
|
||||
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function NumberFieldControls({
|
||||
className,
|
||||
...props
|
||||
}: NumberFieldControlsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(numberFieldControlsVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const numberFieldControlButtonVariants = cva(
|
||||
[
|
||||
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
|
||||
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
|
||||
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
|
||||
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
regular: '',
|
||||
large: '',
|
||||
},
|
||||
direction: {
|
||||
increment: '',
|
||||
decrement: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: 'regular',
|
||||
direction: 'increment',
|
||||
className: 'pt-1',
|
||||
},
|
||||
{
|
||||
size: 'regular',
|
||||
direction: 'decrement',
|
||||
className: 'pb-1',
|
||||
},
|
||||
{
|
||||
size: 'large',
|
||||
direction: 'increment',
|
||||
className: 'pt-1.5',
|
||||
},
|
||||
{
|
||||
size: 'large',
|
||||
direction: 'decrement',
|
||||
className: 'pb-1.5',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
direction: 'increment',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type NumberFieldButtonVariantProps = Omit<
|
||||
VariantProps<typeof numberFieldControlButtonVariants>,
|
||||
'direction'
|
||||
>
|
||||
|
||||
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
|
||||
|
||||
export function NumberFieldIncrement({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
return (
|
||||
<BaseNumberField.Increment
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberFieldDecrement({
|
||||
className,
|
||||
size = 'regular',
|
||||
...props
|
||||
}: NumberFieldButtonProps) {
|
||||
return (
|
||||
<BaseNumberField.Decrement
|
||||
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
93
web/app/components/billing/pricing/__tests__/dialog.spec.tsx
Normal file
93
web/app/components/billing/pricing/__tests__/dialog.spec.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../../type'
|
||||
import { render } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
import Pricing from '../index'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: DialogProps['onOpenChange']
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="dialog">{children}</div>
|
||||
},
|
||||
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<button data-testid="pricing-header-close" onClick={onClose}>close</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../plan-switcher', () => ({
|
||||
default: () => <div>plan-switcher</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../plans', () => ({
|
||||
default: () => <div>plans</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../footer', () => ({
|
||||
default: () => <div>footer</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
const buildUsage = (): UsagePlanInfo => ({
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
})
|
||||
|
||||
describe('Pricing dialog lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.sandbox,
|
||||
usage: buildUsage(),
|
||||
total: buildUsage(),
|
||||
},
|
||||
})
|
||||
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
|
||||
})
|
||||
|
||||
it('should only call onCancel when the dialog requests closing', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
24
web/app/components/billing/pricing/header.module.css
Normal file
24
web/app/components/billing/pricing/header.module.css
Normal file
@ -0,0 +1,24 @@
|
||||
.instrumentSerif {
|
||||
font-family: "Instrument Serif", serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Instrument Serif";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("./InstrumentSerif-Italic-Latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF,
|
||||
U+0100-024F,
|
||||
U+0259,
|
||||
U+0300-036F,
|
||||
U+1E00-1EFF,
|
||||
U+2010-205E,
|
||||
U+20A0-20CF,
|
||||
U+2113,
|
||||
U+2212,
|
||||
U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogDescription, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Button from '../../base/button'
|
||||
import DifyLogo from '../../base/logo/dify-logo'
|
||||
import styles from './header.module.css'
|
||||
|
||||
type HeaderProps = {
|
||||
onClose: () => void
|
||||
@ -20,13 +21,18 @@ const Header = ({
|
||||
<div className="py-[5px]">
|
||||
<DifyLogo className="h-[27px] w-[60px]" />
|
||||
</div>
|
||||
<DialogTitle className="m-0 bg-billing-plan-title-bg bg-clip-text px-1.5 font-instrument text-[37px] italic leading-[1.2] text-transparent">
|
||||
<span
|
||||
className={cn(
|
||||
'bg-billing-plan-title-bg bg-clip-text px-1.5 text-[37px] leading-[1.2] text-transparent',
|
||||
styles.instrumentSerif,
|
||||
)}
|
||||
>
|
||||
{t('plansCommon.title.plans', { ns: 'billing' })}
|
||||
</DialogTitle>
|
||||
</span>
|
||||
</div>
|
||||
<DialogDescription className="m-0 text-text-tertiary system-sm-regular">
|
||||
<p className="text-text-tertiary system-sm-regular">
|
||||
{t('plansCommon.title.description', { ns: 'billing' })}
|
||||
</DialogDescription>
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
|
||||
|
||||
@ -4,13 +4,6 @@ import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from '../index'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock RadioCard
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
|
||||
@ -50,7 +43,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
})
|
||||
|
||||
it('should render correctly with full config', () => {
|
||||
render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
@ -59,7 +52,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
|
||||
// Check Icon
|
||||
const icon = screen.getByTestId('method-icon')
|
||||
const icon = container.querySelector('img')
|
||||
expect(icon).toHaveAttribute('src', 'vector-icon.png')
|
||||
|
||||
// Check Config Details
|
||||
@ -87,18 +80,18 @@ describe('RetrievalMethodInfo', () => {
|
||||
it('should handle different retrieval methods', () => {
|
||||
// Test Hybrid
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
describe('getIcon utility', () => {
|
||||
@ -132,17 +125,17 @@ describe('RetrievalMethodInfo', () => {
|
||||
|
||||
it('should render correctly with invertedIndex search method', () => {
|
||||
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
|
||||
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
|
||||
// invertedIndex uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
|
||||
it('should render correctly with keywordSearch search method', () => {
|
||||
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
|
||||
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
|
||||
// keywordSearch uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RadioCard from '@/app/components/base/radio-card'
|
||||
@ -28,7 +27,7 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const type = value.search_method
|
||||
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RadioCard
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
@ -215,11 +214,11 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
isChosen={value.reranking_mode === option.value}
|
||||
onChosen={() => handleChangeRerankMode(option.value)}
|
||||
icon={(
|
||||
<Image
|
||||
<img
|
||||
src={
|
||||
option.value === RerankingModeEnum.WeightedScore
|
||||
? ProgressIndicator
|
||||
: Reranking
|
||||
? ProgressIndicator.src
|
||||
: Reranking.src
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
}))
|
||||
|
||||
// Override global next/image auto-mock: test asserts on data-testid="next-image"
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
// eslint-disable-next-line next/no-img-element
|
||||
<img src={src} alt={alt} className={className} data-testid="next-image" />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock API service
|
||||
const mockFetchIndexingStatusBatch = vi.fn()
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
@ -979,9 +971,9 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should render correct icon for indexing type', () => {
|
||||
render(<RuleDetail indexingType="high_quality" />)
|
||||
const { container } = render(<RuleDetail indexingType="high_quality" />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import Image from 'next/image'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
|
||||
@ -119,12 +118,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={indexModeLabel}
|
||||
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={retrievalLabel}
|
||||
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg'
|
||||
import Selection from './assets/selection-mod.svg'
|
||||
|
||||
export const indexMethodIcon = {
|
||||
high_quality: GoldIcon,
|
||||
economical: Piggybank,
|
||||
high_quality: GoldIcon.src,
|
||||
economical: Piggybank.src,
|
||||
}
|
||||
|
||||
export const retrievalIcon = {
|
||||
vector: Selection,
|
||||
fullText: Research,
|
||||
hybrid: PatternRecognition,
|
||||
vector: Selection.src,
|
||||
fullText: Research.src,
|
||||
hybrid: PatternRecognition.src,
|
||||
}
|
||||
|
||||
@ -47,19 +47,19 @@ describe('MaxLengthInput', () => {
|
||||
|
||||
it('should render number input', () => {
|
||||
render(<MaxLengthInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept value prop', () => {
|
||||
render(<MaxLengthInput value={500} onChange={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('500')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('500')
|
||||
})
|
||||
|
||||
it('should have min of 1', () => {
|
||||
render(<MaxLengthInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -75,18 +75,18 @@ describe('OverlapInput', () => {
|
||||
|
||||
it('should render number input', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept value prop', () => {
|
||||
render(<OverlapInput value={50} onChange={vi.fn()} />)
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('50')
|
||||
})
|
||||
|
||||
it('should have min of 1', () => {
|
||||
render(<OverlapInput onChange={vi.fn()} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,13 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OptionCard, OptionCardHeader } from '../option-card'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on rendered <img> elements
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
|
||||
<img src={src} alt={alt} {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('OptionCardHeader', () => {
|
||||
const defaultProps = {
|
||||
icon: <span data-testid="icon">icon</span>,
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
RiAlertFill,
|
||||
RiSearchEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
|
||||
<OptionCard
|
||||
className="mb-2 bg-background-section"
|
||||
title={t('stepTwo.general', { ns: 'datasetCreation' })}
|
||||
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
|
||||
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
|
||||
isActive={isActive}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@ -98,7 +97,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
|
||||
icon={<img src={indexMethodIcon.high_quality} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
|
||||
disabled={hasSetIndexType}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
|
||||
@ -143,7 +142,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
|
||||
className="h-full"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.economical} alt="" />}
|
||||
icon={<img src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ComponentProps, FC, ReactNode } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const TriangleArrow: FC<ComponentProps<'svg'>> = props => (
|
||||
@ -23,7 +22,7 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
|
||||
return (
|
||||
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
|
||||
<div className="relative flex size-14 items-center justify-center overflow-hidden">
|
||||
{isActive && effectImg && <Image src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
|
||||
{isActive && effectImg && <img src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
|
||||
<div className="p-1">
|
||||
<div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md">
|
||||
{icon}
|
||||
|
||||
@ -4,7 +4,6 @@ import type { FC } from 'react'
|
||||
import type { ParentChildConfig } from '../hooks'
|
||||
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { RiSearchEyeLine } from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
@ -118,7 +117,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<Image src={Note} alt="" />}
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
@ -140,7 +139,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<Image src={FileList} alt="" />}
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@ -55,6 +55,21 @@ const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): Cr
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
}
|
||||
|
||||
// FireCrawl Component Tests
|
||||
|
||||
describe('FireCrawl', () => {
|
||||
@ -217,7 +232,7 @@ describe('FireCrawl', () => {
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -241,7 +256,7 @@ describe('FireCrawl', () => {
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -277,6 +292,10 @@ describe('FireCrawl', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onJobIdChange with job_id from API response', async () => {
|
||||
@ -301,6 +320,10 @@ describe('FireCrawl', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
|
||||
@ -334,11 +357,23 @@ describe('FireCrawl', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading state while running', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
const createTaskDeferred = createDeferred<{ job_id: string }>()
|
||||
mockCreateFirecrawlTask.mockImplementation(() => createTaskDeferred.promise)
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
@ -352,6 +387,14 @@ describe('FireCrawl', () => {
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
createTaskDeferred.resolve({ job_id: 'test-job-id' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -656,7 +699,7 @@ describe('FireCrawl', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// Total should be capped to limit (5)
|
||||
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@ -35,6 +35,22 @@ enum Step {
|
||||
finished = 'finished',
|
||||
}
|
||||
|
||||
type CrawlState = {
|
||||
current: number
|
||||
total: number
|
||||
data: CrawlResultItem[]
|
||||
time_consuming: number | string
|
||||
}
|
||||
|
||||
type CrawlFinishedResult = {
|
||||
isCancelled?: boolean
|
||||
isError: boolean
|
||||
errorMessage?: string
|
||||
data: Partial<CrawlState> & {
|
||||
data: CrawlResultItem[]
|
||||
}
|
||||
}
|
||||
|
||||
const FireCrawl: FC<Props> = ({
|
||||
onPreview,
|
||||
checkedCrawlResult,
|
||||
@ -46,10 +62,16 @@ const FireCrawl: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<Step>(Step.init)
|
||||
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
|
||||
const isMountedRef = useRef(true)
|
||||
useEffect(() => {
|
||||
if (step !== Step.init)
|
||||
setControlFoldOptions(Date.now())
|
||||
}, [step])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const handleSetting = useCallback(() => {
|
||||
setShowAccountSettingModal({
|
||||
@ -85,16 +107,19 @@ const FireCrawl: FC<Props> = ({
|
||||
const isInit = step === Step.init
|
||||
const isCrawlFinished = step === Step.finished
|
||||
const isRunning = step === Step.running
|
||||
const [crawlResult, setCrawlResult] = useState<{
|
||||
current: number
|
||||
total: number
|
||||
data: CrawlResultItem[]
|
||||
time_consuming: number | string
|
||||
} | undefined>(undefined)
|
||||
const [crawlResult, setCrawlResult] = useState<CrawlState | undefined>(undefined)
|
||||
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
|
||||
const showError = isCrawlFinished && crawlErrorMessage
|
||||
|
||||
const waitForCrawlFinished = useCallback(async (jobId: string) => {
|
||||
const waitForCrawlFinished = useCallback(async (jobId: string): Promise<CrawlFinishedResult> => {
|
||||
const cancelledResult: CrawlFinishedResult = {
|
||||
isCancelled: true,
|
||||
isError: false,
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await checkFirecrawlTaskStatus(jobId) as any
|
||||
if (res.status === 'completed') {
|
||||
@ -104,7 +129,7 @@ const FireCrawl: FC<Props> = ({
|
||||
...res,
|
||||
total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)),
|
||||
},
|
||||
}
|
||||
} satisfies CrawlFinishedResult
|
||||
}
|
||||
if (res.status === 'error' || !res.status) {
|
||||
// can't get the error message from the firecrawl api
|
||||
@ -114,12 +139,14 @@ const FireCrawl: FC<Props> = ({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
} satisfies CrawlFinishedResult
|
||||
}
|
||||
res.data = res.data.map((item: any) => ({
|
||||
...item,
|
||||
content: item.markdown,
|
||||
}))
|
||||
if (!isMountedRef.current)
|
||||
return cancelledResult
|
||||
// update the progress
|
||||
setCrawlResult({
|
||||
...res,
|
||||
@ -127,17 +154,21 @@ const FireCrawl: FC<Props> = ({
|
||||
})
|
||||
onCheckedCrawlResultChange(res.data || []) // default select the crawl result
|
||||
await sleep(2500)
|
||||
if (!isMountedRef.current)
|
||||
return cancelledResult
|
||||
return await waitForCrawlFinished(jobId)
|
||||
}
|
||||
catch (e: any) {
|
||||
const errorBody = await e.json()
|
||||
if (!isMountedRef.current)
|
||||
return cancelledResult
|
||||
const errorBody = typeof e?.json === 'function' ? await e.json() : undefined
|
||||
return {
|
||||
isError: true,
|
||||
errorMessage: errorBody.message,
|
||||
errorMessage: errorBody?.message,
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
}
|
||||
} satisfies CrawlFinishedResult
|
||||
}
|
||||
}, [crawlOptions.limit, onCheckedCrawlResultChange])
|
||||
|
||||
@ -162,24 +193,31 @@ const FireCrawl: FC<Props> = ({
|
||||
url,
|
||||
options: passToServerCrawlOptions,
|
||||
}) as any
|
||||
if (!isMountedRef.current)
|
||||
return
|
||||
const jobId = res.job_id
|
||||
onJobIdChange(jobId)
|
||||
const { isError, data, errorMessage } = await waitForCrawlFinished(jobId)
|
||||
const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId)
|
||||
if (isCancelled || !isMountedRef.current)
|
||||
return
|
||||
if (isError) {
|
||||
setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' }))
|
||||
}
|
||||
else {
|
||||
setCrawlResult(data)
|
||||
setCrawlResult(data as CrawlState)
|
||||
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
|
||||
setCrawlErrorMessage('')
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (!isMountedRef.current)
|
||||
return
|
||||
setCrawlErrorMessage(t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' })!)
|
||||
console.log(e)
|
||||
}
|
||||
finally {
|
||||
setStep(Step.finished)
|
||||
if (isMountedRef.current)
|
||||
setStep(Step.finished)
|
||||
}
|
||||
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])
|
||||
|
||||
|
||||
@ -6,14 +6,6 @@ import { ProcessMode } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import RuleDetail from '../rule-detail'
|
||||
|
||||
// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes
|
||||
vi.mock('next/image', () => ({
|
||||
default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) {
|
||||
// eslint-disable-next-line next/no-img-element
|
||||
return <img src={src} alt={alt} className={className} data-testid="next-image" />
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock FieldInfo component
|
||||
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
|
||||
FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => (
|
||||
@ -184,16 +176,16 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should show high_quality icon for qualified indexing', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg')
|
||||
})
|
||||
|
||||
it('should show economical icon for economical indexing', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[0]).toHaveAttribute('src', '/icons/economical.svg')
|
||||
})
|
||||
})
|
||||
@ -256,38 +248,38 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should show vector icon for semantic search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.semantic}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
|
||||
})
|
||||
|
||||
it('should show fullText icon for full text search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.fullText}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg')
|
||||
})
|
||||
|
||||
it('should show hybrid icon for hybrid search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<RuleDetail
|
||||
indexingType={IndexingType.QUALIFIED}
|
||||
retrievalMethod={RETRIEVE_METHOD.hybrid}
|
||||
/>,
|
||||
)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg')
|
||||
})
|
||||
})
|
||||
@ -308,9 +300,9 @@ describe('RuleDetail', () => {
|
||||
})
|
||||
|
||||
it('should handle undefined retrievalMethod with defined indexingType', () => {
|
||||
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
|
||||
|
||||
const images = screen.getAllByTestId('next-image')
|
||||
const images = container.querySelectorAll('img')
|
||||
// When retrievalMethod is undefined, vector icon is used as default
|
||||
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -50,7 +49,7 @@ const RuleDetail = ({
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
@ -65,7 +64,7 @@ const RuleDetail = ({
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessRuleResponse } from '@/models/datasets'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -101,7 +100,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
|
||||
alt=""
|
||||
@ -112,7 +111,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
<img
|
||||
className="size-4"
|
||||
src={getRetrievalIcon(retrievalMethod)}
|
||||
alt=""
|
||||
|
||||
@ -905,8 +905,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The TopKItem should render an input
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
// The TopKItem renders the visible number-field input as a textbox.
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const topKInput = inputs[0]
|
||||
fireEvent.change(topKInput, { target: { value: '8' } })
|
||||
|
||||
@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// The ScoreThresholdItem should render an input
|
||||
const inputs = screen.getAllByRole('spinbutton')
|
||||
// The ScoreThresholdItem renders the visible number-field input as a textbox.
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const scoreThresholdInput = inputs[1]
|
||||
fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } })
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiPlayCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -178,7 +177,7 @@ const QueryInput = ({
|
||||
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
|
||||
|
||||
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
|
||||
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
|
||||
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
|
||||
const TextAreaComp = useMemo(() => {
|
||||
return (
|
||||
<Textarea
|
||||
|
||||
@ -43,8 +43,9 @@ describe('InputCombined', () => {
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('42')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('42')
|
||||
})
|
||||
|
||||
it('should render date picker for time type', () => {
|
||||
@ -96,7 +97,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
@ -108,7 +109,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('999')
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to number input', () => {
|
||||
@ -117,7 +118,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
@ -186,7 +187,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -208,7 +209,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('rounded-l-md')
|
||||
})
|
||||
})
|
||||
@ -230,7 +231,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should handle negative number', () => {
|
||||
@ -239,7 +240,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('-100')
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
@ -263,7 +264,7 @@ describe('InputCombined', () => {
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -129,15 +129,15 @@ describe('IndexMethod', () => {
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
|
||||
const handleKeywordChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleKeywordChange).toHaveBeenCalled()
|
||||
@ -192,14 +192,14 @@ describe('IndexMethod', () => {
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should handle max keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('50')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -38,15 +38,15 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should render input number field', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display correct keywordNumber value in input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
})
|
||||
|
||||
it('should display different keywordNumber values', () => {
|
||||
@ -54,8 +54,8 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
values.forEach((value) => {
|
||||
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(value)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue(String(value))
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@ -82,7 +82,7 @@ describe('KeyWordNumber', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
@ -92,7 +92,7 @@ describe('KeyWordNumber', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
|
||||
@ -117,32 +117,32 @@ describe('KeyWordNumber', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle minimum value (0)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
|
||||
it('should handle maximum value (50)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('50')
|
||||
})
|
||||
|
||||
it('should handle value updates correctly', () => {
|
||||
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
|
||||
|
||||
let input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(10)
|
||||
let input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('10')
|
||||
|
||||
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('25')
|
||||
})
|
||||
|
||||
it('should handle rapid value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
// Simulate rapid changes via input with different values
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should have accessible input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
@ -11,21 +10,6 @@ vi.mock('../use-get-requirements', () => ({
|
||||
default: (...args: unknown[]) => mockUseGetRequirements(...args),
|
||||
}))
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
unoptimized: _unoptimized,
|
||||
...rest
|
||||
}: {
|
||||
src: string
|
||||
alt: string
|
||||
unoptimized?: boolean
|
||||
} & ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
React.createElement('img', { src, alt, ...rest })
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App Name',
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
@ -38,14 +37,13 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
<img
|
||||
className="size-5 rounded-md object-cover shadow-xs"
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={requirementIconSize}
|
||||
height={requirementIconSize}
|
||||
unoptimized
|
||||
onError={() => setFailedSource(iconUrl)}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import EditWorkspaceModal from './index'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: DialogProps['onOpenChange']
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="dialog">{children}</div>
|
||||
},
|
||||
DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />,
|
||||
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('EditWorkspaceModal dialog lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
currentWorkspace: { name: 'Test Workspace' },
|
||||
isCurrentWorkspaceOwner: true,
|
||||
} as never)
|
||||
})
|
||||
|
||||
it('should only call onCancel when the dialog requests closing', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
|
||||
<EditWorkspaceModal onCancel={onCancel} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -120,6 +120,21 @@ describe('EditWorkspaceModal', () => {
|
||||
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not submit when the form is submitted while save is disabled', async () => {
|
||||
renderModal()
|
||||
|
||||
const saveButton = screen.getByTestId('edit-workspace-save')
|
||||
const form = saveButton.closest('form')
|
||||
|
||||
expect(saveButton).toBeDisabled()
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
fireEvent.submit(form!)
|
||||
|
||||
expect(updateWorkspaceInfo).not.toHaveBeenCalled()
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable confirm button for non-owners', async () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import MenuDialog from './menu-dialog'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: DialogProps['onOpenChange']
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="dialog">{children}</div>
|
||||
},
|
||||
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('MenuDialog dialog lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
})
|
||||
|
||||
it('should only call onClose when the dialog requests closing', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<MenuDialog show={true} onClose={onClose}>
|
||||
<div>Content</div>
|
||||
</MenuDialog>,
|
||||
)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -100,6 +100,42 @@ describe('deriveModelStatus', () => {
|
||||
).toBe('api-key-unavailable')
|
||||
})
|
||||
|
||||
it('should return credits-exhausted when model status is quota exceeded', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem({ status: ModelStatusEnum.quotaExceeded }),
|
||||
createCredentialState({ priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('should return api-key-unavailable when model status is credential removed', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem({ status: ModelStatusEnum.credentialRemoved }),
|
||||
createCredentialState({ priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('api-key-unavailable')
|
||||
})
|
||||
|
||||
it('should return incompatible when model status is no-permission', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem({ status: ModelStatusEnum.noPermission }),
|
||||
createCredentialState({ priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return active when model and credential state are available', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem(), createCredentialState()),
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
QuotaUnitEnum,
|
||||
} from './declarations'
|
||||
import ModelProviderPage from './index'
|
||||
|
||||
const mockQuotaConfig = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_unit: QuotaUnitEnum.times,
|
||||
quota_limit: 100,
|
||||
quota_used: 1,
|
||||
last_used: 0,
|
||||
is_valid: true,
|
||||
}
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
IS_CLOUD_EDITION: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
enable_marketplace: false,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useDefaultModel: () => ({ data: null, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('./provider-added-card', () => ({
|
||||
default: () => <div data-testid="provider-card" />,
|
||||
}))
|
||||
|
||||
vi.mock('./provider-added-card/quota-panel', () => ({
|
||||
default: () => <div data-testid="quota-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('./system-model-selector', () => ({
|
||||
default: () => <div data-testid="system-model-selector" />,
|
||||
}))
|
||||
|
||||
vi.mock('./install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({ data: undefined }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
plugins: {
|
||||
checkInstalled: { queryOptions: () => ({}) },
|
||||
latestVersions: { queryOptions: () => ({}) },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ModelProviderPage non-cloud branch', () => {
|
||||
it('should skip the quota panel when cloud edition is disabled', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -61,8 +61,15 @@ describe('InstallFromMarketplace', () => {
|
||||
|
||||
it('should collapse when clicked', () => {
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
fireEvent.click(screen.getByText('common.modelProvider.installProvider'))
|
||||
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state', () => {
|
||||
|
||||
@ -0,0 +1,271 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Credential, ModelProvider } from '../declarations'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../declarations'
|
||||
import ModelModal from './index'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
type AlertDialogProps = {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let mockLanguage = 'en_US'
|
||||
let latestDialogOnOpenChange: DialogProps['onOpenChange']
|
||||
let latestAlertDialogOnOpenChange: AlertDialogProps['onOpenChange']
|
||||
let mockAvailableCredentials: Credential[] | undefined = []
|
||||
let mockDeleteCredentialId: string | null = null
|
||||
|
||||
const mockCloseConfirmDelete = vi.fn()
|
||||
const mockHandleConfirmDelete = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: () => <div data-testid="auth-form" />,
|
||||
}))
|
||||
|
||||
vi.mock('../model-auth', () => ({
|
||||
CredentialSelector: ({ credentials }: { credentials: Credential[] }) => <div>{`credentials:${credentials.length}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestDialogOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogCloseButton: () => <button type="button">close</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
|
||||
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
|
||||
latestAlertDialogOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
|
||||
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../model-auth/hooks', () => ({
|
||||
useCredentialData: () => ({
|
||||
isLoading: false,
|
||||
credentialData: {
|
||||
credentials: {},
|
||||
available_credentials: mockAvailableCredentials,
|
||||
},
|
||||
}),
|
||||
useAuth: () => ({
|
||||
handleSaveCredential: vi.fn(),
|
||||
handleConfirmDelete: mockHandleConfirmDelete,
|
||||
deleteCredentialId: mockDeleteCredentialId,
|
||||
closeConfirmDelete: mockCloseConfirmDelete,
|
||||
openConfirmDelete: vi.fn(),
|
||||
doingAction: false,
|
||||
handleActiveCredential: vi.fn(),
|
||||
}),
|
||||
useModelFormSchemas: () => ({
|
||||
formSchemas: [],
|
||||
formValues: {},
|
||||
modelNameAndTypeFormSchemas: [],
|
||||
modelNameAndTypeFormValues: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (value: Record<string, string>) => value[mockLanguage] || value.en_US,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useLanguage: () => mockLanguage,
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
help: {
|
||||
title: { en_US: 'Help', zh_Hans: '帮助' },
|
||||
url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' },
|
||||
},
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
supported_model_types: [],
|
||||
configurate_methods: [],
|
||||
provider_credential_schema: { credential_form_schemas: [] },
|
||||
model_credential_schema: {
|
||||
model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } },
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
custom_configuration: {
|
||||
status: 'active',
|
||||
available_credentials: [],
|
||||
custom_models: [],
|
||||
can_added_models: [],
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'trial',
|
||||
quota_configurations: [],
|
||||
},
|
||||
allow_custom_token: true,
|
||||
...overrides,
|
||||
} as unknown as ModelProvider)
|
||||
|
||||
describe('ModelModal dialog branches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en_US'
|
||||
latestDialogOnOpenChange = undefined
|
||||
latestAlertDialogOnOpenChange = undefined
|
||||
mockAvailableCredentials = []
|
||||
mockDeleteCredentialId = null
|
||||
})
|
||||
|
||||
it('should only cancel when the dialog reports it has closed', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<ModelModal
|
||||
provider={createProvider()}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
onCancel={onCancel}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
latestDialogOnOpenChange?.(true)
|
||||
latestDialogOnOpenChange?.(false)
|
||||
})
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should only close the confirm dialog when the alert dialog closes', () => {
|
||||
mockDeleteCredentialId = 'cred-1'
|
||||
|
||||
render(
|
||||
<ModelModal
|
||||
provider={createProvider()}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
latestAlertDialogOnOpenChange?.(true)
|
||||
latestAlertDialogOnOpenChange?.(false)
|
||||
})
|
||||
|
||||
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should pass an empty credential list to the selector when no credentials are available', () => {
|
||||
mockAvailableCredentials = undefined
|
||||
|
||||
render(
|
||||
<ModelModal
|
||||
provider={createProvider()}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
mode={ModelModalModeEnum.addCustomModelToModelList}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('credentials:0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the help link when provider help is missing', () => {
|
||||
render(
|
||||
<ModelModal
|
||||
provider={createProvider({ help: undefined })}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Help' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prevent navigation when help text exists without a help url', () => {
|
||||
mockLanguage = 'zh_Hans'
|
||||
|
||||
render(
|
||||
<ModelModal
|
||||
provider={createProvider({
|
||||
help: {
|
||||
title: { en_US: 'English Help' },
|
||||
url: '' as unknown as ModelProvider['help']['url'],
|
||||
} as ModelProvider['help'],
|
||||
})}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const link = screen.getByText('English Help').closest('a')
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
expect(link).not.toBeNull()
|
||||
link!.dispatchEvent(clickEvent)
|
||||
|
||||
expect(clickEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('should fall back to localized and english help urls when titles are missing', () => {
|
||||
mockLanguage = 'zh_Hans'
|
||||
const { rerender } = render(
|
||||
<ModelModal
|
||||
provider={createProvider({
|
||||
help: {
|
||||
url: { zh_Hans: 'https://example.cn', en_US: 'https://example.com' },
|
||||
} as ModelProvider['help'],
|
||||
})}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute('href', 'https://example.cn')
|
||||
|
||||
rerender(
|
||||
<ModelModal
|
||||
provider={createProvider({
|
||||
help: {
|
||||
url: { en_US: 'https://example.com' },
|
||||
} as ModelProvider['help'],
|
||||
})}
|
||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://example.com' })
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
link.dispatchEvent(clickEvent)
|
||||
|
||||
expect(link).toHaveAttribute('href', 'https://example.com')
|
||||
expect(clickEvent.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ModelParameterModal from './index'
|
||||
|
||||
let isAPIKeySet = true
|
||||
@ -77,9 +77,10 @@ vi.mock('./parameter-item', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../model-selector', () => ({
|
||||
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
|
||||
default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => (
|
||||
<div data-testid="model-selector">
|
||||
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
|
||||
<button onClick={onHide}>hide</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -231,4 +232,67 @@ describe('ModelParameterModal', () => {
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support custom triggers, workflow mode, and missing default model values', async () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
provider=""
|
||||
modelId=""
|
||||
isInWorkflow
|
||||
renderTrigger={({ open }) => <span>{open ? 'Custom Open' : 'Custom Closed'}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Custom Closed'))
|
||||
|
||||
expect(screen.getByText('Custom Open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('hide'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should append the stop parameter in advanced mode and show the single-model debug label', () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
isAdvancedMode
|
||||
debugWithMultipleModel
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
|
||||
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the empty loading fallback when rules resolve to an empty list', () => {
|
||||
parameterRules = []
|
||||
isRulesLoading = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support custom trigger placement outside workflow mode', () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
renderTrigger={({ open }) => <span>{open ? 'Popup Open' : 'Popup Closed'}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Popup Closed'))
|
||||
|
||||
expect(screen.getByText('Popup Open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ParameterItem from './parameter-item'
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/select', () => ({
|
||||
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
|
||||
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectValue: () => <div>SelectValue</div>,
|
||||
}))
|
||||
|
||||
describe('ParameterItem select mode', () => {
|
||||
it('should propagate both explicit and empty select values', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ParameterItem
|
||||
parameterRule={{
|
||||
name: 'format',
|
||||
label: { en_US: 'Format', zh_Hans: 'Format' },
|
||||
type: 'string',
|
||||
options: ['json', 'text'],
|
||||
required: false,
|
||||
help: { en_US: 'Help', zh_Hans: 'Help' },
|
||||
}}
|
||||
value="json"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'select-updated' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'select-empty' }))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, 'updated')
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, undefined)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModelSelector from './index'
|
||||
|
||||
type PopoverProps = {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: PopoverProps['onOpenChange']
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useCurrentProviderAndModel: () => ({
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/popover', () => ({
|
||||
Popover: ({ children, onOpenChange }: PopoverProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./model-selector-trigger', () => ({
|
||||
default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => (
|
||||
<span>
|
||||
{open ? 'open' : 'closed'}
|
||||
-
|
||||
{readonly ? 'readonly' : 'editable'}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./popup', () => ({
|
||||
default: ({ onHide }: { onHide: () => void }) => (
|
||||
<div data-testid="popup">
|
||||
<button type="button" onClick={onHide}>hide-popup</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ModelSelector popover branches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
})
|
||||
|
||||
it('should open and close through popover callbacks when editable', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<ModelSelector modelList={[]} onHide={onHide} />)
|
||||
|
||||
act(() => {
|
||||
latestOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
expect(screen.getByText('open-editable')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'hide-popup' }))
|
||||
|
||||
expect(screen.getByText('closed-editable')).toBeInTheDocument()
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should ignore popover open changes when readonly', () => {
|
||||
render(<ModelSelector modelList={[]} readonly />)
|
||||
|
||||
act(() => {
|
||||
latestOnOpenChange?.(true)
|
||||
})
|
||||
|
||||
expect(screen.getByText('closed-readonly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -12,12 +12,13 @@ import PopupItem from './popup-item'
|
||||
|
||||
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
|
||||
const mockUseLanguage = vi.hoisted(() => vi.fn(() => 'en_US'))
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => 'en_US',
|
||||
useLanguage: mockUseLanguage,
|
||||
useUpdateModelList: () => mockUpdateModelList,
|
||||
useUpdateModelProviders: () => mockUpdateModelProviders,
|
||||
}
|
||||
@ -43,6 +44,12 @@ vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: mockCredentialPanelState,
|
||||
@ -56,7 +63,7 @@ vi.mock('../provider-added-card/use-change-provider-priority', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/model-auth-dropdown/dropdown-content', () => ({
|
||||
default: () => null,
|
||||
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>close dropdown</button>,
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
|
||||
@ -110,6 +117,7 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
|
||||
describe('PopupItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseLanguage.mockReturnValue('en_US')
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider()],
|
||||
})
|
||||
@ -215,6 +223,24 @@ describe('PopupItem', () => {
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to english labels when the current language is unavailable', () => {
|
||||
mockUseLanguage.mockReturnValue('zh_Hans')
|
||||
|
||||
render(
|
||||
<PopupItem
|
||||
model={makeModel({
|
||||
label: { en_US: 'OpenAI only' } as Model['label'],
|
||||
models: [makeModelItem({ label: { en_US: 'GPT-4 only' } as ModelItem['label'] })],
|
||||
})}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('OpenAI only')).toBeInTheDocument()
|
||||
expect(screen.getByText('GPT-4 only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapsed state when clicking provider header', () => {
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
|
||||
@ -235,6 +261,24 @@ describe('PopupItem', () => {
|
||||
expect(screen.getByText('my-api-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the inactive credential badge when the api key is not active', () => {
|
||||
mockCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-inactive',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: 'stale-key',
|
||||
credits: 200,
|
||||
})
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('stale-key')).toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should show configure required when no credential name', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider({
|
||||
@ -306,4 +350,14 @@ describe('PopupItem', () => {
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the dropdown through dropdown content callbacks', () => {
|
||||
const onHide = vi.fn()
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={onHide} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -553,4 +553,60 @@ describe('Popup', () => {
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip install requests when marketplace plugins are still loading', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockMarketplacePlugins.isLoading = true
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip install requests when the marketplace plugin cannot be found', async () => {
|
||||
mockMarketplacePlugins.current = []
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should sort the selected provider to the top when a default model is provided', () => {
|
||||
render(
|
||||
<Popup
|
||||
defaultModel={{ provider: 'anthropic', model: 'claude-3' }}
|
||||
modelList={[
|
||||
makeModel({ provider: 'openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' } }),
|
||||
makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' } }),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const providerLabels = screen.getAllByText(/openai|anthropic/)
|
||||
expect(providerLabels[0]).toHaveTextContent('anthropic')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CreditsExhaustedAlert from './credits-exhausted-alert'
|
||||
|
||||
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
Trans: ({
|
||||
i18nKey,
|
||||
components,
|
||||
}: {
|
||||
i18nKey?: string
|
||||
components: { upgradeLink: ReactNode }
|
||||
}) => (
|
||||
<>
|
||||
{i18nKey}
|
||||
{components.upgradeLink}
|
||||
</>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../use-trial-credits', () => ({
|
||||
useTrialCredits: () => mockTrialCredits,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: () => mockSetShowPricingModal,
|
||||
}))
|
||||
|
||||
describe('CreditsExhaustedAlert', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(mockTrialCredits, { credits: 0 })
|
||||
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 10_000 })
|
||||
})
|
||||
|
||||
// Without API key fallback
|
||||
@ -59,5 +84,21 @@ describe('CreditsExhaustedAlert', () => {
|
||||
expect(screen.getByText(/9,800/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/10,000/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cap progress at 100 percent when total credits are zero', () => {
|
||||
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 0 })
|
||||
|
||||
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
|
||||
|
||||
expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('should open the pricing modal when the upgrade link is clicked', () => {
|
||||
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
|
||||
|
||||
fireEvent.click(container.querySelector('button') as HTMLButtonElement)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import type { CredentialPanelState } from '../use-credential-panel-state'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import DropdownContent from './dropdown-content'
|
||||
|
||||
type AlertDialogProps = {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: AlertDialogProps['onOpenChange']
|
||||
const mockOpenConfirmDelete = vi.fn()
|
||||
const mockCloseConfirmDelete = vi.fn()
|
||||
const mockHandleConfirmDelete = vi.fn()
|
||||
const mockHandleOpenModal = vi.fn()
|
||||
|
||||
vi.mock('../../model-auth/hooks', () => ({
|
||||
useAuth: () => ({
|
||||
openConfirmDelete: mockOpenConfirmDelete,
|
||||
closeConfirmDelete: mockCloseConfirmDelete,
|
||||
doingAction: false,
|
||||
handleConfirmDelete: mockHandleConfirmDelete,
|
||||
deleteCredentialId: 'cred-1',
|
||||
handleOpenModal: mockHandleOpenModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./use-activate-credential', () => ({
|
||||
useActivateCredential: () => ({
|
||||
selectedCredentialId: 'cred-1',
|
||||
isActivating: false,
|
||||
activate: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
|
||||
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
|
||||
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogDescription: () => <div />,
|
||||
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./api-key-section', () => ({
|
||||
default: ({ credentials, onDelete }: { credentials: unknown[], onDelete: (credential?: unknown) => void }) => (
|
||||
<div>
|
||||
<span>{`credentials:${credentials.length}`}</span>
|
||||
<button type="button" onClick={() => onDelete(undefined)}>delete-undefined</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./credits-exhausted-alert', () => ({
|
||||
default: () => <div>credits alert</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./credits-fallback-alert', () => ({
|
||||
default: () => <div>fallback alert</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./usage-priority-section', () => ({
|
||||
default: () => <div>priority section</div>,
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
|
||||
provider: 'test',
|
||||
custom_configuration: {
|
||||
available_credentials: undefined,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
quota_configurations: [],
|
||||
current_quota_type: 'trial',
|
||||
},
|
||||
configurate_methods: [],
|
||||
supported_model_types: [],
|
||||
...overrides,
|
||||
} as unknown as ModelProvider)
|
||||
|
||||
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKey',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: false,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DropdownContent dialog branches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
})
|
||||
|
||||
it('should fall back to an empty credential list when the provider has no credentials', () => {
|
||||
render(
|
||||
<DropdownContent
|
||||
provider={createProvider()}
|
||||
state={createState()}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('credentials:0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore delete requests without a credential payload', () => {
|
||||
render(
|
||||
<DropdownContent
|
||||
provider={createProvider()}
|
||||
state={createState()}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'delete-undefined' }))
|
||||
|
||||
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only close the confirm dialog when the alert dialog reports closed', () => {
|
||||
render(
|
||||
<DropdownContent
|
||||
provider={createProvider()}
|
||||
state={createState()}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
})
|
||||
|
||||
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -223,4 +223,42 @@ describe('ProviderCardActions', () => {
|
||||
expect(mockSetTargetVersion).not.toHaveBeenCalled()
|
||||
expect(mockHandleUpdate).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should fall back to the detail name when declaration metadata is missing', () => {
|
||||
render(
|
||||
<ProviderCardActions
|
||||
detail={createDetail({
|
||||
declaration: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins//provider-plugin', {
|
||||
language: 'en-US',
|
||||
theme: 'light',
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave the detail url empty when a GitHub plugin has no repo or the source is unsupported', () => {
|
||||
const { rerender } = render(
|
||||
<ProviderCardActions
|
||||
detail={createDetail({
|
||||
source: PluginSource.github,
|
||||
meta: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
|
||||
|
||||
rerender(
|
||||
<ProviderCardActions
|
||||
detail={createDetail({
|
||||
source: PluginSource.local,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
|
||||
})
|
||||
})
|
||||
|
||||
@ -12,7 +12,7 @@ let mockWorkspaceData: {
|
||||
next_credit_reset_date: '2024-12-31',
|
||||
}
|
||||
let mockWorkspaceIsPending = false
|
||||
let mockTrialModels: string[] = ['langgenius/openai/openai']
|
||||
let mockTrialModels: string[] | undefined = ['langgenius/openai/openai']
|
||||
let mockPlugins = [{
|
||||
plugin_id: 'langgenius/openai',
|
||||
latest_package_identifier: 'openai@1.0.0',
|
||||
@ -39,9 +39,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
trial_models: mockTrialModels,
|
||||
},
|
||||
data: mockTrialModels ? { trial_models: mockTrialModels } : undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -149,4 +147,37 @@ describe('QuotaPanel', () => {
|
||||
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should tolerate missing trial model configuration', () => {
|
||||
mockTrialModels = undefined
|
||||
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render installed custom providers without opening the install modal', () => {
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
expect(screen.getByLabelText(/modelAPI/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('openai'))
|
||||
|
||||
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the supported-model tooltip for installed non-custom providers', () => {
|
||||
render(
|
||||
<QuotaPanel providers={[
|
||||
{
|
||||
provider: 'langgenius/openai/openai',
|
||||
preferred_provider_type: 'system',
|
||||
custom_configuration: { available_credentials: [] },
|
||||
},
|
||||
] as unknown as ModelProvider[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText(/modelSupported/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -172,6 +172,24 @@ describe('useCredentialPanelState', () => {
|
||||
|
||||
expect(result.current.variant).toBe('api-unavailable')
|
||||
})
|
||||
|
||||
it('should return api-required-configure when credentials exist but the current credential is incomplete', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
mockTrialCredits.credits = 0
|
||||
const provider = createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: 'cred-1',
|
||||
current_credential_name: undefined,
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCredentialPanelState(provider))
|
||||
|
||||
expect(result.current.variant).toBe('api-required-configure')
|
||||
})
|
||||
})
|
||||
|
||||
// apiKeyOnly priority
|
||||
|
||||
@ -74,6 +74,26 @@ describe('ProviderIcon', () => {
|
||||
expect(screen.getByTestId('openai-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className to special provider wrappers', () => {
|
||||
const { rerender, container } = render(
|
||||
<ProviderIcon
|
||||
provider={createProvider({ provider: 'langgenius/anthropic/anthropic' })}
|
||||
className="custom-wrapper"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-wrapper')
|
||||
|
||||
rerender(
|
||||
<ProviderIcon
|
||||
provider={createProvider({ provider: 'langgenius/openai/openai' })}
|
||||
className="custom-wrapper"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-wrapper')
|
||||
})
|
||||
|
||||
it('should render generic provider with image and label', () => {
|
||||
const provider = createProvider({ label: { en_US: 'Custom', zh_Hans: '自定义' } })
|
||||
render(<ProviderIcon provider={provider} />)
|
||||
@ -94,4 +114,19 @@ describe('ProviderIcon', () => {
|
||||
const img = screen.getByAltText('provider-icon') as HTMLImageElement
|
||||
expect(img.src).toBe('https://example.com/dark.png')
|
||||
})
|
||||
|
||||
it('should fall back to localized labels when available', () => {
|
||||
const mockLang = vi.mocked(useLanguage)
|
||||
mockLang.mockReturnValue('zh_Hans')
|
||||
|
||||
render(
|
||||
<ProviderIcon
|
||||
provider={createProvider({
|
||||
label: { en_US: 'Custom', zh_Hans: '自定义' },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('自定义')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -115,6 +115,12 @@ describe('SystemModel', () => {
|
||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render the primary button variant when configuration is required', () => {
|
||||
render(<SystemModel {...defaultProps} notConfigured />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /system model settings/i })).toHaveClass('btn-primary')
|
||||
})
|
||||
|
||||
it('should close dialog when cancel is clicked', async () => {
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
@ -151,6 +157,27 @@ describe('SystemModel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the dialog open when saving does not succeed', async () => {
|
||||
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'failed' })
|
||||
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateDefaultModel).not.toHaveBeenCalled()
|
||||
expect(mockUpdateModelList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable save when user is not workspace manager', async () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
render(<SystemModel {...defaultProps} />)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Form, ValidateValue } from '../key-validator/declarations'
|
||||
import type { PluginProvider } from '@/models/common'
|
||||
import Image from 'next/image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -64,7 +63,7 @@ const SerpapiPlugin = ({
|
||||
return (
|
||||
<KeyValidator
|
||||
type="serpapi"
|
||||
title={<Image alt="serpapi logo" src={SerpapiLogo} width={64} />}
|
||||
title={<img alt="serpapi logo" src={SerpapiLogo.src} width={64} />}
|
||||
status={plugin.credentials?.api_key ? 'success' : 'add'}
|
||||
forms={forms}
|
||||
keyFrom={{
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
import type { TagKey } from '../constants'
|
||||
import type { Plugin } from '../types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { getValidCategoryKeys, getValidTagKeys } from '../utils'
|
||||
import { getPluginCardIconUrl, getValidCategoryKeys, getValidTagKeys } from '../utils'
|
||||
|
||||
const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({
|
||||
from: 'github',
|
||||
name: 'demo-plugin',
|
||||
org: 'langgenius',
|
||||
type: 'plugin',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('plugins/utils', () => {
|
||||
describe('getValidTagKeys', () => {
|
||||
@ -47,4 +57,31 @@ describe('plugins/utils', () => {
|
||||
expect(getValidCategoryKeys('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPluginCardIconUrl', () => {
|
||||
it('returns an empty string when icon is missing', () => {
|
||||
expect(getPluginCardIconUrl(createPlugin(), undefined, 'tenant-1')).toBe('')
|
||||
})
|
||||
|
||||
it('returns absolute urls and root-relative urls as-is', () => {
|
||||
expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe('https://example.com/icon.png')
|
||||
expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe('/icons/demo.png')
|
||||
})
|
||||
|
||||
it('builds the marketplace icon url for plugins and bundles', () => {
|
||||
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1'))
|
||||
.toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`)
|
||||
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace', type: 'bundle' }), 'icon.png', 'tenant-1'))
|
||||
.toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`)
|
||||
})
|
||||
|
||||
it('falls back to the raw icon when tenant id is missing for non-marketplace plugins', () => {
|
||||
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', '')).toBe('icon.png')
|
||||
})
|
||||
|
||||
it('builds the workspace icon url for tenant-scoped plugins', () => {
|
||||
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1'))
|
||||
.toBe(`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
128
web/app/components/plugins/hooks.spec.ts
Normal file
128
web/app/components/plugins/hooks.spec.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { PluginDetail } from './types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { usePluginsWithLatestVersion } from './hooks'
|
||||
import { PluginSource } from './types'
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
plugins: {
|
||||
latestVersions: {
|
||||
queryOptions: vi.fn((options: unknown) => options),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'plugin-1',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
name: 'demo-plugin',
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'plugin-1@1.0.0',
|
||||
declaration: {} as PluginDetail['declaration'],
|
||||
installation_id: 'installation-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'plugin-1@1.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('usePluginsWithLatestVersion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useQuery).mockReturnValue({ data: undefined } as never)
|
||||
})
|
||||
|
||||
it('should disable latest-version querying when there are no marketplace plugins', () => {
|
||||
const plugins = [
|
||||
createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
|
||||
|
||||
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
|
||||
input: { body: { plugin_ids: [] } },
|
||||
enabled: false,
|
||||
})
|
||||
expect(result.current).toEqual(plugins)
|
||||
})
|
||||
|
||||
it('should return the original plugins when version data is unavailable', () => {
|
||||
const plugins = [createPlugin()]
|
||||
|
||||
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
|
||||
|
||||
expect(result.current).toEqual(plugins)
|
||||
})
|
||||
|
||||
it('should keep plugins unchanged when a plugin has no matching latest version', () => {
|
||||
const plugins = [createPlugin()]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { versions: {} },
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
|
||||
|
||||
expect(result.current).toEqual(plugins)
|
||||
})
|
||||
|
||||
it('should merge latest version fields for marketplace plugins with version data', () => {
|
||||
const plugins = [
|
||||
createPlugin(),
|
||||
createPlugin({
|
||||
id: 'plugin-2',
|
||||
plugin_id: 'plugin-2',
|
||||
plugin_unique_identifier: 'plugin-2@1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'plugin-2@1.0.0',
|
||||
source: PluginSource.github,
|
||||
}),
|
||||
]
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: {
|
||||
versions: {
|
||||
'plugin-1': {
|
||||
version: '1.1.0',
|
||||
unique_identifier: 'plugin-1@1.1.0',
|
||||
status: 'deleted',
|
||||
deprecated_reason: 'replaced',
|
||||
alternative_plugin_id: 'plugin-3',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
|
||||
|
||||
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
|
||||
input: { body: { plugin_ids: ['plugin-1'] } },
|
||||
enabled: true,
|
||||
})
|
||||
expect(result.current).toEqual([
|
||||
expect.objectContaining({
|
||||
plugin_id: 'plugin-1',
|
||||
latest_version: '1.1.0',
|
||||
latest_unique_identifier: 'plugin-1@1.1.0',
|
||||
status: 'deleted',
|
||||
deprecated_reason: 'replaced',
|
||||
alternative_plugin_id: 'plugin-3',
|
||||
}),
|
||||
plugins[1],
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -267,6 +267,34 @@ describe('useInstallMultiState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to latest_version when marketplace plugin version is missing', async () => {
|
||||
mockMarketplaceData = {
|
||||
data: {
|
||||
list: [{
|
||||
plugin: {
|
||||
plugin_id: 'test-org/plugin-0',
|
||||
org: 'test-org',
|
||||
name: 'Test Plugin 0',
|
||||
version: '',
|
||||
latest_version: '2.0.0',
|
||||
},
|
||||
version: {
|
||||
unique_identifier: 'plugin-0-uid',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins[0]?.version).toBe('2.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
it('should resolve marketplace dependency from organization and plugin fields', async () => {
|
||||
mockMarketplaceData = createMarketplaceApiData([0])
|
||||
|
||||
@ -293,6 +321,44 @@ describe('useInstallMultiState', () => {
|
||||
|
||||
// ==================== Error Handling ====================
|
||||
describe('Error Handling', () => {
|
||||
it('should mark marketplace index as error when identifier misses plugin and version parts', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'invalid-identifier',
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark marketplace index as error when identifier has an empty plugin segment', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'test-org/:1.0.0',
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark marketplace index as error when identifier is missing', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
@ -344,6 +410,19 @@ describe('useInstallMultiState', () => {
|
||||
expect(result.current.errorIndexes).not.toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore marketplace requests whose dsl index cannot be mapped', () => {
|
||||
const duplicatedMarketplaceDependency = createMarketplaceDependency(0)
|
||||
const allPlugins = [duplicatedMarketplaceDependency] as Dependency[]
|
||||
|
||||
allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter
|
||||
|
||||
const params = createDefaultParams({ allPlugins })
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
expect(result.current.plugins).toHaveLength(1)
|
||||
expect(result.current.errorIndexes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Loaded All Data Notification ====================
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -11,7 +10,7 @@ const PipelineScreenShot = () => {
|
||||
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline.png`} />
|
||||
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@2x.png`} />
|
||||
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@3x.png`} />
|
||||
<Image
|
||||
<img
|
||||
src={`${basePath}/screenshots/${theme}/Pipeline.png`}
|
||||
alt="Pipeline Screenshot"
|
||||
width={692}
|
||||
|
||||
131
web/app/components/workflow/header/checklist/index.spec.tsx
Normal file
131
web/app/components/workflow/header/checklist/index.spec.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import WorkflowChecklist from './index'
|
||||
|
||||
let mockChecklistItems = [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Missing Plugin',
|
||||
errorMessages: [],
|
||||
canNavigate: false,
|
||||
isPluginMissing: true,
|
||||
},
|
||||
{
|
||||
id: 'node-1',
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Broken Node',
|
||||
errorMessages: ['Needs configuration'],
|
||||
canNavigate: true,
|
||||
isPluginMissing: false,
|
||||
},
|
||||
]
|
||||
|
||||
const mockHandleNodeSelect = vi.fn()
|
||||
|
||||
type PopoverProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: PopoverProps['onOpenChange']
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useEdges: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useChecklist: () => mockChecklistItems,
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/popover', () => ({
|
||||
Popover: ({ children, onOpenChange }: PopoverProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="popover">{children}</div>
|
||||
},
|
||||
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('./plugin-group', () => ({
|
||||
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./node-group', () => ({
|
||||
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
|
||||
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
|
||||
{item.title}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('WorkflowChecklist', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
mockChecklistItems = [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Missing Plugin',
|
||||
errorMessages: [],
|
||||
canNavigate: false,
|
||||
isPluginMissing: true,
|
||||
},
|
||||
{
|
||||
id: 'node-1',
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Broken Node',
|
||||
errorMessages: ['Needs configuration'],
|
||||
canNavigate: true,
|
||||
isPluginMissing: false,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => {
|
||||
render(<WorkflowChecklist disabled={false} />)
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin')
|
||||
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
|
||||
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
})
|
||||
|
||||
it('should use the custom item click handler when provided', () => {
|
||||
const onItemClick = vi.fn()
|
||||
render(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
|
||||
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the resolved state when there are no checklist warnings', () => {
|
||||
mockChecklistItems = []
|
||||
|
||||
render(<WorkflowChecklist disabled={false} />)
|
||||
|
||||
expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore popover open changes when the checklist is disabled', () => {
|
||||
render(<WorkflowChecklist disabled={true} />)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
|
||||
expect(screen.getByText('2').closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { ChecklistNodeGroup } from './node-group'
|
||||
|
||||
vi.mock('../../block-icon', () => ({
|
||||
default: () => <div data-testid="block-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('./item-indicator', () => ({
|
||||
ItemIndicator: () => <div data-testid="item-indicator" />,
|
||||
}))
|
||||
|
||||
const createItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'node-1',
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Broken Node',
|
||||
errorMessages: ['Needs configuration'],
|
||||
canNavigate: true,
|
||||
disableGoTo: false,
|
||||
unConnected: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ChecklistNodeGroup', () => {
|
||||
it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ChecklistNodeGroup
|
||||
item={createItem({ unConnected: true }) as never}
|
||||
showGoTo={true}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Needs configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/goToFix/i)).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Needs configuration'))
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
|
||||
})
|
||||
|
||||
it('should not allow navigation when go-to is disabled', () => {
|
||||
const onItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ChecklistNodeGroup
|
||||
item={createItem({ disableGoTo: true }) as never}
|
||||
showGoTo={true}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Needs configuration'))
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -76,4 +76,21 @@ describe('ChecklistPluginGroup', () => {
|
||||
fireEvent.click(installButton)
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
|
||||
})
|
||||
|
||||
it('should omit the version when the marketplace identifier does not include one', () => {
|
||||
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })])
|
||||
|
||||
fireEvent.click(getInstallButton())
|
||||
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256',
|
||||
plugin_unique_identifier: 'langgenius/test-plugin@sha256',
|
||||
version: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
150
web/app/components/workflow/header/run-mode.spec.tsx
Normal file
150
web/app/components/workflow/header/run-mode.spec.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import RunMode from './run-mode'
|
||||
import { TriggerType } from './test-run-menu'
|
||||
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn()
|
||||
const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
let mockWarningNodes: Array<{ id: string }> = []
|
||||
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined
|
||||
let mockIsListening = false
|
||||
let mockDynamicOptions = [
|
||||
{ type: TriggerType.UserInput, nodeId: 'start-node' },
|
||||
]
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowStartRun: () => ({
|
||||
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow,
|
||||
}),
|
||||
useWorkflowRun: () => ({
|
||||
handleStopRun: mockHandleStopRun,
|
||||
}),
|
||||
useWorkflowRunValidation: () => ({
|
||||
warningNodes: mockWarningNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
|
||||
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
|
||||
useDynamicTestRunOptions: () => mockDynamicOptions,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
StopCircle: () => <span data-testid="stop-circle" />,
|
||||
}))
|
||||
|
||||
vi.mock('./test-run-menu', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./test-run-menu')>()
|
||||
return {
|
||||
...actual,
|
||||
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
toggle: vi.fn(),
|
||||
}))
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="trigger-option" onClick={() => onSelect(options[0])}>
|
||||
Trigger option
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('RunMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWarningNodes = []
|
||||
mockWorkflowRunningData = undefined
|
||||
mockIsListening = false
|
||||
mockDynamicOptions = [
|
||||
{ type: TriggerType.UserInput, nodeId: 'start-node' },
|
||||
]
|
||||
})
|
||||
|
||||
it('should render the run trigger and start the workflow when a valid trigger is selected', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText(/run/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('trigger-option'))
|
||||
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' })
|
||||
})
|
||||
|
||||
it('should show an error toast instead of running when the selected trigger has checklist warnings', () => {
|
||||
mockWarningNodes = [{ id: 'start-node' }]
|
||||
|
||||
render(<RunMode />)
|
||||
fireEvent.click(screen.getByTestId('trigger-option'))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.panel.checklistTip',
|
||||
})
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the running state and stop the workflow when it is already running', () => {
|
||||
mockWorkflowRunningData = {
|
||||
result: { status: WorkflowRunningStatus.Running },
|
||||
task_id: 'task-1',
|
||||
}
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText(/running/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement)
|
||||
|
||||
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
|
||||
})
|
||||
|
||||
it('should render the listening label when the workflow is listening', () => {
|
||||
mockIsListening = true
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText(/listening/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,253 @@
|
||||
import type { CommonNodeType } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useNodePluginInstallation } from '../use-node-plugin-installation'
|
||||
|
||||
const mockBuiltInTools = vi.fn()
|
||||
const mockCustomTools = vi.fn()
|
||||
const mockWorkflowTools = vi.fn()
|
||||
const mockMcpTools = vi.fn()
|
||||
const mockInvalidToolsByType = vi.fn()
|
||||
const mockTriggerPlugins = vi.fn()
|
||||
const mockInvalidateTriggers = vi.fn()
|
||||
const mockInvalidDataSourceList = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
|
||||
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),
|
||||
useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled),
|
||||
useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled),
|
||||
useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled),
|
||||
useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useInvalidDataSourceList: () => mockInvalidDataSourceList,
|
||||
}))
|
||||
|
||||
const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool node',
|
||||
desc: '',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'search',
|
||||
provider_name: 'search',
|
||||
plugin_id: 'plugin-search',
|
||||
plugin_unique_identifier: 'plugin-search@1.0.0',
|
||||
...overrides,
|
||||
}) as CommonNodeType
|
||||
|
||||
const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
title: 'Trigger node',
|
||||
desc: '',
|
||||
provider_id: 'trigger-provider',
|
||||
provider_name: 'trigger-provider',
|
||||
plugin_id: 'trigger-plugin',
|
||||
plugin_unique_identifier: 'trigger-plugin@1.0.0',
|
||||
...overrides,
|
||||
}) as CommonNodeType
|
||||
|
||||
const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Data source node',
|
||||
desc: '',
|
||||
provider_name: 'knowledge-provider',
|
||||
plugin_id: 'knowledge-plugin',
|
||||
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
|
||||
...overrides,
|
||||
}) as CommonNodeType
|
||||
|
||||
const matchedTool = {
|
||||
plugin_id: 'plugin-search',
|
||||
provider: 'search',
|
||||
name: 'search',
|
||||
plugin_unique_identifier: 'plugin-search@1.0.0',
|
||||
}
|
||||
|
||||
const matchedTriggerProvider = {
|
||||
id: 'trigger-provider',
|
||||
name: 'trigger-provider',
|
||||
plugin_id: 'trigger-plugin',
|
||||
}
|
||||
|
||||
const matchedDataSource = {
|
||||
provider: 'knowledge-provider',
|
||||
plugin_id: 'knowledge-plugin',
|
||||
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
|
||||
}
|
||||
|
||||
describe('useNodePluginInstallation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockCustomTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockMcpTools.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockInvalidToolsByType.mockReturnValue(undefined)
|
||||
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false })
|
||||
mockInvalidateTriggers.mockReset()
|
||||
mockInvalidDataSourceList.mockReset()
|
||||
})
|
||||
|
||||
it('should return the noop installation state for non plugin-dependent nodes', () => {
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation({
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
} as CommonNodeType),
|
||||
)
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: expect.any(Function),
|
||||
shouldDim: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should report loading and invalidate built-in tools while the collection is resolving', () => {
|
||||
const invalidateTools = vi.fn()
|
||||
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true })
|
||||
mockInvalidToolsByType.mockReturnValue(invalidateTools)
|
||||
|
||||
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()))
|
||||
|
||||
expect(mockBuiltInTools).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isChecking).toBe(true)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0')
|
||||
expect(result.current.canInstall).toBe(true)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onInstallSuccess()
|
||||
})
|
||||
|
||||
expect(invalidateTools).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[CollectionType.custom, mockCustomTools],
|
||||
[CollectionType.workflow, mockWorkflowTools],
|
||||
[CollectionType.mcp, mockMcpTools],
|
||||
])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => {
|
||||
hookMock.mockReturnValue({ data: [matchedTool], isLoading: false })
|
||||
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeToolNode({ provider_type: providerType })),
|
||||
)
|
||||
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep unknown tool collection types installable without collection state', () => {
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeToolNode({
|
||||
provider_type: 'unknown' as CollectionType,
|
||||
plugin_unique_identifier: undefined,
|
||||
plugin_id: undefined,
|
||||
provider_id: 'legacy-provider',
|
||||
})),
|
||||
)
|
||||
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.uniqueIdentifier).toBe('legacy-provider')
|
||||
expect(result.current.canInstall).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(false)
|
||||
})
|
||||
|
||||
it('should flag missing trigger plugins and invalidate trigger data after installation', () => {
|
||||
mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false })
|
||||
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeTriggerNode({
|
||||
provider_id: 'missing-trigger',
|
||||
provider_name: 'missing-trigger',
|
||||
plugin_id: 'missing-trigger',
|
||||
})),
|
||||
)
|
||||
|
||||
expect(mockTriggerPlugins).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isChecking).toBe(false)
|
||||
expect(result.current.isMissing).toBe(true)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onInstallSuccess()
|
||||
})
|
||||
|
||||
expect(mockInvalidateTriggers).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should treat the trigger plugin list as still loading when it has not resolved yet', () => {
|
||||
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
const { result } = renderWorkflowHook(() =>
|
||||
useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })),
|
||||
)
|
||||
|
||||
expect(result.current.isChecking).toBe(true)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.uniqueIdentifier).toBe('trigger-plugin')
|
||||
expect(result.current.canInstall).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
})
|
||||
|
||||
it('should track missing and matched data source providers based on workflow store state', () => {
|
||||
const missingRender = renderWorkflowHook(
|
||||
() => useNodePluginInstallation(makeDataSourceNode({
|
||||
provider_name: 'missing-provider',
|
||||
plugin_id: 'missing-plugin',
|
||||
plugin_unique_identifier: 'missing-plugin@1.0.0',
|
||||
})),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [matchedDataSource] as never,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(missingRender.result.current.isChecking).toBe(false)
|
||||
expect(missingRender.result.current.isMissing).toBe(true)
|
||||
expect(missingRender.result.current.shouldDim).toBe(true)
|
||||
|
||||
const matchedRender = renderWorkflowHook(
|
||||
() => useNodePluginInstallation(makeDataSourceNode()),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [matchedDataSource] as never,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(matchedRender.result.current.isMissing).toBe(false)
|
||||
expect(matchedRender.result.current.shouldDim).toBe(false)
|
||||
|
||||
act(() => {
|
||||
matchedRender.result.current.onInstallSuccess()
|
||||
})
|
||||
|
||||
expect(mockInvalidDataSourceList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep data sources in checking state before the list is loaded', () => {
|
||||
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode()))
|
||||
|
||||
expect(result.current.isChecking).toBe(true)
|
||||
expect(result.current.isMissing).toBe(false)
|
||||
expect(result.current.shouldDim).toBe(true)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user