diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 4f00a9101c..a19cb50abc 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -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 diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 8999b8d544..ec9d1c98c8 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -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 diff --git a/api/.env.example b/api/.env.example index 8195a3c074..40e1c2dfdf 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/configs/middleware/cache/redis_pubsub_config.py b/api/configs/middleware/cache/redis_pubsub_config.py index 8cddc5677a..d30831a0ec 100644 --- a/api/configs/middleware/cache/redis_pubsub_config.py +++ b/api/configs/middleware/cache/redis_pubsub_config.py @@ -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, ) diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py index aef1afb235..d015769b54 100644 --- a/api/core/mcp/auth/auth_flow.py +++ b/api/core/mcp/auth/auth_flow.py @@ -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}" diff --git a/api/dify_graph/nodes/human_input/entities.py b/api/dify_graph/nodes/human_input/entities.py index 7936e47213..2a33b4a0a8 100644 --- a/api/dify_graph/nodes/human_input/entities.py +++ b/api/dify_graph/nodes/human_input/entities.py @@ -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.""" diff --git a/api/pyproject.toml b/api/pyproject.toml index 57d58ce5b8..841a877328 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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 diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py index 80deb37a56..229e6608da 100644 --- a/api/services/human_input_delivery_test_service.py +++ b/api/services/human_input_delivery_test_service.py @@ -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, diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index bded4cea2b..d241783359 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -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, ) diff --git a/api/tests/test_containers_integration_tests/conftest.py b/api/tests/test_containers_integration_tests/conftest.py index 2a23f1ea7d..0bdd3bdc47 100644 --- a/api/tests/test_containers_integration_tests/conftest.py +++ b/api/tests/test_containers_integration_tests/conftest.py @@ -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) diff --git a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py index abf3c60fe0..fe533e62af 100644 --- a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py +++ b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py @@ -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( diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py index d4939b1071..d52dfa2a65 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py @@ -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 "Bold" in rendered + assert 'link' in rendered + + +def test_render_markdown_body_sanitizes_unsafe_html(): + rendered = EmailDeliveryConfig.render_markdown_body( + 'Click' + ) + + assert "bad" in rendered + assert 'ok' in rendered + + +def test_render_markdown_body_does_not_allow_raw_html_tags(): + rendered = EmailDeliveryConfig.render_markdown_body("raw html and **markdown**") + + assert "" not in rendered + assert "raw html" in rendered + assert "markdown" in rendered + + +def test_render_markdown_body_supports_table_syntax(): + rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |") + + assert "" in rendered + assert "" in rendered + assert "" 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("Alert") + + assert "<" not in sanitized + assert ">" not in sanitized + assert sanitized == "Alert" diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py index 74139fd12d..a23c44b26e 100644 --- a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py @@ -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="Notice\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()) diff --git a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py index 20cb7a211e..37b7a85451 100644 --- a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py @@ -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"] == "

Body OK

" + + +@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 Alert", + 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" diff --git a/api/uv.lock b/api/uv.lock index 547b2fabc7..c707c574e3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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" }, diff --git a/docker/.env.example b/docker/.env.example index 4ef856b431..9d6cd65318 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index fcd4800143..1804592c0e 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -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. diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 4a739bbbe0..2dca581903 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d5dfdbd16b..d14f0503e7 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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. diff --git a/web/__tests__/check-components-diff-coverage.test.ts b/web/__tests__/check-components-diff-coverage.test.ts index 690c7a512b..79f6c8fd26 100644 --- a/web/__tests__/check-components-diff-coverage.test.ts +++ b/web/__tests__/check-components-diff-coverage.test.ts @@ -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', diff --git a/web/app/components/app/configuration/config-vision/index.spec.tsx b/web/app/components/app/configuration/config-vision/index.spec.tsx index 5fc7648bea..0c6e1346ce 100644 --- a/web/app/components/app/configuration/config-vision/index.spec.tsx +++ b/web/app/components/app/configuration/config-vision/index.spec.tsx @@ -218,7 +218,7 @@ describe('ParamConfigContent', () => { }) render() - const input = screen.getByRole('spinbutton') as HTMLInputElement + const input = screen.getByRole('textbox') as HTMLInputElement fireEvent.change(input, { target: { value: '4' } }) const updatedFile = getLatestFileConfig() diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx index 67d59f2706..7904159109 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx @@ -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 () => { diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.spec.tsx index 2e19d529f3..96fac39c50 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.spec.tsx @@ -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', () => { diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 2f287899a0..1c22913bb1 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -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 }) { -
, })) -// Mock next/image to render a normal img tag for testing -vi.mock('next/image', () => ({ - __esModule: true, - default: (props: ImgHTMLAttributes & { unoptimized?: boolean }) => { - const { unoptimized: _, ...rest } = props - return - }, -})) - type GlobalPublicStoreMock = { systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void diff --git a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx index 7c588f6a33..b4f816dda8 100644 --- a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx +++ b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx @@ -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) => , -})) - describe('checkbox list component', () => { const options = [ { label: 'Option 1', value: 'option1' }, diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index ed328244a1..6eda2aebd0 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -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 = ({ {searchQuery ? (
- + search menu {t('operation.noSearchResults', { ns: 'common', content: title })}
diff --git a/web/app/components/base/file-thumb/__tests__/index.spec.tsx b/web/app/components/base/file-thumb/__tests__/index.spec.tsx index 368f14ae75..f67f291579 100644 --- a/web/app/components/base/file-thumb/__tests__/index.spec.tsx +++ b/web/app/components/base/file-thumb/__tests__/index.spec.tsx @@ -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) => , -})) - describe('FileThumb Component', () => { const mockImageFile = { name: 'test-image.jpg', diff --git a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx index 049e19d75e..84c02f3327 100644 --- a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx @@ -22,7 +22,7 @@ describe('NumberInputField', () => { it('should render current number value', () => { render() - expect(screen.getByDisplayValue('2')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('2') }) it('should update value when users click increment', () => { diff --git a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx index 7de473e4c8..1d7734f670 100644 --- a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx +++ b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx @@ -45,7 +45,7 @@ describe('BaseField', () => { it('should render a number input when configured as number input', () => { render() - expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByText('Age')).toBeInTheDocument() }) diff --git a/web/app/components/base/input-number/__tests__/index.spec.tsx b/web/app/components/base/input-number/__tests__/index.spec.tsx index 53e49a51ed..6056bbf5c0 100644 --- a/web/app/components/base/input-number/__tests__/index.spec.tsx +++ b/web/app/components/base/input-number/__tests__/index.spec.tsx @@ -13,7 +13,7 @@ describe('InputNumber Component', () => { it('renders input with default values', () => { render() - 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() - 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() - 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() - 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() - 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() - 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() + 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() - 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() - 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() - 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() + + 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() + + 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() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '0' } }) expect(onChange).toHaveBeenCalledWith(0) diff --git a/web/app/components/base/input-number/index.tsx b/web/app/components/base/input-number/index.tsx index 102ebfeda1..42aec3f742 100644 --- a/web/app/components/base/input-number/index.tsx +++ b/web/app/components/base/input-number/index.tsx @@ -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, + '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 + type?: 'number' +} + +const STEPPER_REASONS = new Set([ + '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 = (props) => { const { unit, className, + wrapperClassName, + styleCss, onChange, - amount = 1, + amount, value, size = 'regular', max, @@ -34,96 +97,97 @@ export const InputNumber: FC = (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) => { - 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 ( -
- + -
- - -
+ + + {unit && ( + + {unit} + + )} + + + + + + + +
) } diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx index 58eb24d75e..dbe293dcf6 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx @@ -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 & { unoptimized?: boolean }) => , -})) - describe('WithIconCardItem', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx index 915c31f160..9eac1282a9 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx @@ -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 (
- {/* - * unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration. - * https://github.com/vercel/next.js/issues/88873 - */} -
{children} diff --git a/web/app/components/base/markdown-with-directive/index.spec.tsx b/web/app/components/base/markdown-with-directive/index.spec.tsx index 0ca608727f..fc4b813247 100644 --- a/web/app/components/base/markdown-with-directive/index.spec.tsx +++ b/web/app/components/base/markdown-with-directive/index.spec.tsx @@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index' const FOUR_COLON_RE = /:{4}/ -vi.mock('next/image', () => ({ - default: (props: React.ImgHTMLAttributes) => , -})) - function expectDecorativeIcon(container: HTMLElement, src: string) { const icon = container.querySelector('img') expect(icon).toBeInTheDocument() diff --git a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx index efcf015ea5..f1f1cf08d2 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx @@ -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 }) =>
{name}
, })) diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index 60bcbebcf9..b18c10216d 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -53,7 +53,7 @@ describe('ParamItem', () => { it('should render InputNumber and Slider', () => { render() - 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() - 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() - 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() - 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() }) }) }) diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index d59768dacb..026908fa9e 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => { it('should render InputNumber and Slider', () => { render() - 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() - 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() - 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() - 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() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('step', '0.01') + render() + 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() - 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() - const input = screen.getByRole('spinbutton') - expect(input).toHaveValue(1) + const input = screen.getByRole('textbox') + expect(input).toHaveValue('1') }) }) }) diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx index 177b51e768..1b8555213b 100644 --- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx @@ -36,7 +36,7 @@ describe('TopKItem', () => { it('should render InputNumber and Slider', () => { render() - 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() - 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() - 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() - 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() - 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', () => { diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.spec.ts b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.spec.ts new file mode 100644 index 0000000000..f64865317f --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/use-llm-model-plugin-installed.spec.ts @@ -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: (selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T => + selector({ modelProviders: mockModelProviders }), +})) + +const createWorkflowNodesMap = (node: Record): 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) + }) +}) diff --git a/web/app/components/base/tag-input/__tests__/interop.spec.tsx b/web/app/components/base/tag-input/__tests__/interop.spec.tsx new file mode 100644 index 0000000000..f6dd316645 --- /dev/null +++ b/web/app/components/base/tag-input/__tests__/interop.spec.tsx @@ -0,0 +1,57 @@ +import type { ComponentType, InputHTMLAttributes } from 'react' +import { render, screen } from '@testing-library/react' + +const mockNotify = vi.fn() + +type AutosizeInputProps = InputHTMLAttributes & { + inputClassName?: string +} + +const MockAutosizeInput: ComponentType = ({ 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() + + 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() + + expect(screen.getByTestId('autosize-input')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/ui/number-field/__tests__/index.spec.tsx b/web/app/components/base/ui/number-field/__tests__/index.spec.tsx new file mode 100644 index 0000000000..cf0a9a2562 --- /dev/null +++ b/web/app/components/base/ui/number-field/__tests__/index.spec.tsx @@ -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( + + + + + , + ) + + 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( + + + + ms + + + + + + , + ) + + 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( + + + + + + + + + , + ) + + 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') + }) + }) +}) diff --git a/web/app/components/base/ui/number-field/index.tsx b/web/app/components/base/ui/number-field/index.tsx new file mode 100644 index 0000000000..9d58fc9982 --- /dev/null +++ b/web/app/components/base/ui/number-field/index.tsx @@ -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 & VariantProps + +export function NumberFieldGroup({ + className, + size = 'regular', + ...props +}: NumberFieldGroupProps) { + return ( + + ) +} + +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, 'size'> & VariantProps + +export function NumberFieldInput({ + className, + size = 'regular', + ...props +}: NumberFieldInputProps) { + return ( + + ) +} + +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 & VariantProps + +export function NumberFieldUnit({ + className, + size = 'regular', + ...props +}: NumberFieldUnitProps) { + return ( + + ) +} + +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 + +export function NumberFieldControls({ + className, + ...props +}: NumberFieldControlsProps) { + return ( +
+ ) +} + +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, + 'direction' +> + +type NumberFieldButtonProps = React.ComponentPropsWithoutRef & NumberFieldButtonVariantProps + +export function NumberFieldIncrement({ + className, + size = 'regular', + ...props +}: NumberFieldButtonProps) { + return ( + + ) +} + +export function NumberFieldDecrement({ + className, + size = 'regular', + ...props +}: NumberFieldButtonProps) { + return ( + + ) +} diff --git a/web/app/components/billing/pricing/InstrumentSerif-Italic-Latin.woff2 b/web/app/components/billing/pricing/InstrumentSerif-Italic-Latin.woff2 new file mode 100644 index 0000000000..5d1fd32cb0 Binary files /dev/null and b/web/app/components/billing/pricing/InstrumentSerif-Italic-Latin.woff2 differ diff --git a/web/app/components/billing/pricing/__tests__/dialog.spec.tsx b/web/app/components/billing/pricing/__tests__/dialog.spec.tsx new file mode 100644 index 0000000000..c832e52fb2 --- /dev/null +++ b/web/app/components/billing/pricing/__tests__/dialog.spec.tsx @@ -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
{children}
+ }, + DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => ( +
{children}
+ ), +})) + +vi.mock('../header', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( + + ), +})) + +vi.mock('../plan-switcher', () => ({ + default: () =>
plan-switcher
, +})) + +vi.mock('../plans', () => ({ + default: () =>
plans
, +})) + +vi.mock('../footer', () => ({ + default: () =>
footer
, +})) + +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() + + latestOnOpenChange?.(true) + latestOnOpenChange?.(false) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/billing/pricing/header.module.css b/web/app/components/billing/pricing/header.module.css new file mode 100644 index 0000000000..fc05646d86 --- /dev/null +++ b/web/app/components/billing/pricing/header.module.css @@ -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; +} diff --git a/web/app/components/billing/pricing/header.tsx b/web/app/components/billing/pricing/header.tsx index e5107e5677..d0ffe100db 100644 --- a/web/app/components/billing/pricing/header.tsx +++ b/web/app/components/billing/pricing/header.tsx @@ -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 = ({
- + {t('plansCommon.title.plans', { ns: 'billing' })} - +
- +

{t('plansCommon.title.description', { ns: 'billing' })} - +

)} description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} - icon={} + icon={} isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} disabled={hasSetIndexType} onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)} @@ -143,7 +142,7 @@ export const IndexingModeSection: FC = ({ className="h-full" title={t('stepTwo.economical', { ns: 'datasetCreation' })} description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })} - icon={} + icon={} isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} disabled={hasSetIndexType || docForm !== ChunkingMode.text} onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} diff --git a/web/app/components/datasets/create/step-two/components/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx index fb88e9e47f..320c7be44f 100644 --- a/web/app/components/datasets/create/step-two/components/option-card.tsx +++ b/web/app/components/datasets/create/step-two/components/option-card.tsx @@ -1,5 +1,4 @@ import type { ComponentProps, FC, ReactNode } from 'react' -import Image from 'next/image' import { cn } from '@/utils/classnames' const TriangleArrow: FC> = props => ( @@ -23,7 +22,7 @@ export const OptionCardHeader: FC = (props) => { return (
- {isActive && effectImg && } + {isActive && effectImg && }
{icon} diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx index ade6e445ce..eb542fd3d5 100644 --- a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -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 = ({
} + icon={} title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} isChosen={parentChildConfig.chunkForContext === 'paragraph'} @@ -140,7 +139,7 @@ export const ParentChildOptions: FC = ({ /> } + icon={} title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} onChosen={() => onChunkForContextChange('full-doc')} diff --git a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx index 7df3881824..c154c1a534 100644 --- a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx @@ -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 = {}): Cr ...overrides, }) +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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() @@ -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([]) }) }) }) diff --git a/web/app/components/datasets/create/website/firecrawl/index.tsx b/web/app/components/datasets/create/website/firecrawl/index.tsx index 3c5c453b51..09fdbb00c2 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.tsx +++ b/web/app/components/datasets/create/website/firecrawl/index.tsx @@ -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 & { + data: CrawlResultItem[] + } +} + const FireCrawl: FC = ({ onPreview, checkedCrawlResult, @@ -46,10 +62,16 @@ const FireCrawl: FC = ({ const { t } = useTranslation() const [step, setStep] = useState(Step.init) const [controlFoldOptions, setControlFoldOptions] = useState(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 = ({ 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(undefined) const [crawlErrorMessage, setCrawlErrorMessage] = useState('') const showError = isCrawlFinished && crawlErrorMessage - const waitForCrawlFinished = useCallback(async (jobId: string) => { + const waitForCrawlFinished = useCallback(async (jobId: string): Promise => { + 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 = ({ ...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 = ({ 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 = ({ }) 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 = ({ 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]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx index c11caeb156..c0873f2c5d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx @@ -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 {alt} - }, -})) - // 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() + const { container } = render() - 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() + const { container } = render() - 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( , ) - 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( , ) - 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( , ) - 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() + const { container } = render() - 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') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index 8fe6af6170..526d31f3fe 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -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={( - = React.memo(({ label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} valueIcon={( - = React.memo(({ label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} valueIcon={( - { />, ) - // 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' } }) diff --git a/web/app/components/datasets/hit-testing/components/query-input/index.tsx b/web/app/components/datasets/hit-testing/components/query-input/index.tsx index 4b7c16fec3..ebe8581285 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/index.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/index.tsx @@ -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 = + const icon = const TextAreaComp = useMemo(() => { return (