Compare commits

..
Author SHA1 Message Date
yyh 29c7469206 fix: knip 2026-07-23 17:07:04 +08:00
yyh 19858061e6 test(web): seed system features in component tests 2026-07-23 16:51:44 +08:00
yyh 0cc2d69177 test(api): define change email config in integration tests 2026-07-23 16:38:46 +08:00
yyh f6bdd9c673 refactor(web): narrow system features state consumers 2026-07-23 16:07:22 +08:00
yyh 795febe737 refactor(web): simplify server system features query 2026-07-23 16:04:41 +08:00
yyh fa4e4ac34a refactor(web): simplify root query dehydration 2026-07-23 16:04:41 +08:00
yyh 9476581992 refactor(web): scope server query clients by hydration owner 2026-07-23 16:04:41 +08:00
yyh 06ad009c48 fix(web): remove root global error boundary 2026-07-23 16:04:41 +08:00
yyh bb41da013b refactor(web): remove system features fallbacks 2026-07-23 16:04:41 +08:00
yyh 997993196b fix(web): require system features at root 2026-07-23 16:03:05 +08:00
yyh dda75e4192 fix(web): preserve system features query failures 2026-07-23 16:03:05 +08:00
yyh 410e827263 style(api): format deployment edition fixture 2026-07-23 16:03:05 +08:00
yyh c457182f4d fix(web): prevent duplicate partner attribution processing 2026-07-23 16:03:05 +08:00
yyh d2596222d7 style(web): format deployment edition migration 2026-07-23 16:03:05 +08:00
yyh df17f15bcb fix: address deployment edition migration regressions 2026-07-23 16:03:05 +08:00
yyh 0a777fe7ff chore(docker): scope deployment settings to backend services 2026-07-23 16:03:05 +08:00
yyh 258a9fbfd4 test(web): cover deployment edition behavior 2026-07-23 16:03:05 +08:00
yyh 6e21df1bb1 chore(web): remove browser edition configuration 2026-07-23 16:03:05 +08:00
yyh 64b7cfce24 refactor(web): source deployment edition from system features 2026-07-23 16:03:05 +08:00
yyh 447a5b1076 chore(contracts): regenerate deployment edition schema 2026-07-23 16:03:04 +08:00
yyh eaa41ff318 feat(api): expose deployment edition in system features 2026-07-23 16:03:04 +08:00
Xiyuan ChenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>yyhyyh
b56ac4af4d feat: gate agent management behind the agent.manage permission (#39330)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-23 07:34:07 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2a0661a769 feat(dataset): add New Knowledge creation flow (#39315)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-23 07:25:14 +00:00
euxGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
00fe96325d fix(web): support TTS playback in Safari and Firefox (#39444)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-23 06:36:52 +00:00
非法操作andGitHub a3c18c561e chore: improve lambda plugin runtime error display (#39437) 2026-07-23 06:05:14 +00:00
329 changed files with 5868 additions and 3030 deletions
+5
View File
@@ -1497,6 +1497,11 @@ class LoginConfig(BaseSettings):
class AccountConfig(BaseSettings):
ENABLE_CHANGE_EMAIL: bool = Field(
description="whether users can change their email address",
default=True,
)
ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
description="Duration in minutes for which a account deletion token remains valid",
default=5,
+2 -29
View File
@@ -1,17 +1,8 @@
import os
from typing import Any, Literal, TypedDict, cast
from urllib.parse import parse_qsl, quote_plus
from urllib.parse import urlparse as _urlparse
from pydantic import (
Field,
NonNegativeFloat,
NonNegativeInt,
PositiveFloat,
PositiveInt,
computed_field,
model_validator,
)
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
from pydantic_settings import BaseSettings
from .cache.redis_config import RedisConfig
@@ -417,22 +408,4 @@ class MiddlewareConfig(
DatasetQueueMonitorConfig,
MatrixoneConfig,
):
@model_validator(mode="after")
def _validate_redis_urls_db_for_azure(self):
"""Azure Managed Redis only supports db 0; reject non-zero db in Redis URLs."""
if not self.REDIS_USE_AZURE_MANAGED_IDENTITY:
return self
for url, name in (
(self.CELERY_BROKER_URL, "CELERY_BROKER_URL"),
(self.PUBSUB_REDIS_URL, "PUBSUB_REDIS_URL"),
):
if not url:
continue
db: str = _urlparse(url).path.lstrip("/") or "0"
if db != "0":
raise ValueError(
f"Azure Managed Redis only supports db 0, but {name} uses db {db}. "
"Please set the db index to 0 in your URL."
)
return self
pass
+4 -32
View File
@@ -1,4 +1,4 @@
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator, model_validator
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator
from pydantic_settings import BaseSettings
@@ -42,13 +42,6 @@ class RedisConfig(BaseSettings):
default=False,
)
REDIS_USE_AZURE_MANAGED_IDENTITY: bool = Field(
description="Use Azure Managed Identity (Entra ID) for Redis authentication."
" When enabled, username/password are ignored and a token is acquired via DefaultAzureCredential."
" Requires azure-identity and redis-entraid packages.",
default=False,
)
REDIS_SSL_CERT_REQS: str = Field(
description="SSL certificate requirements (CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED)",
default="CERT_NONE",
@@ -165,33 +158,12 @@ class RedisConfig(BaseSettings):
REDIS_KEEPALIVE_INTERVAL: PositiveInt = Field(default=10, description="redis keepalive interval")
REDIS_KEEPALIVE_COUNT: PositiveInt = Field(default=10, description="redis keepalive count")
@field_validator(
"REDIS_SSL_CA_CERTS",
"REDIS_SSL_CERTFILE",
"REDIS_SSL_KEYFILE",
"REDIS_MAX_CONNECTIONS",
mode="before",
)
@field_validator("REDIS_MAX_CONNECTIONS", mode="before")
@classmethod
def _empty_string_to_none(cls, v):
"""Allow empty string in env/.env to mean 'unset' (None).
Particularly important for SSL file paths: an empty string would cause
redis-py to call ``ssl.SSLContext.load_verify_locations(cafile="")``
which raises ``FileNotFoundError``.
"""
def _empty_string_to_none_for_max_conns(cls, v):
"""Allow empty string in env/.env to mean 'unset' (None)."""
if v is None:
return None
if isinstance(v, str) and v.strip() == "":
return None
return v
@model_validator(mode="after")
def _validate_azure_managed_identity(self):
"""Azure Managed Redis only supports db 0."""
if self.REDIS_USE_AZURE_MANAGED_IDENTITY and self.REDIS_DB != 0:
raise ValueError(
f"Azure Managed Redis only supports db 0, but REDIS_DB is set to {self.REDIS_DB}. "
"Please set REDIS_DB=0 when REDIS_USE_AZURE_MANAGED_IDENTITY is enabled."
)
return self
-2
View File
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .workspace import plugin_model_providers as _plugin_model_providers
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
@@ -36,7 +35,6 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_plugin_model_providers",
"_runtime_credentials",
"_workspace",
"api",
@@ -1,39 +0,0 @@
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.plugin.plugin_service import PluginService
class InvalidatePluginModelProvidersCachePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_invalidate_plugin_model_providers_cache",
responses={
200: "Cache invalidated",
400: "Invalid request",
401: "Unauthorized - invalid API key",
},
)
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
def post(self):
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
for tenant_id in args.tenant_ids:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
return {"result": "success"}, 200
+14 -1
View File
@@ -1,7 +1,7 @@
import inspect
import json
import logging
from collections.abc import Callable, Generator
from collections.abc import Callable, Generator, Mapping
from typing import Any, cast
from urllib.parse import unquote
@@ -23,6 +23,7 @@ from core.plugin.impl.exc import (
PluginLLMPollingUnsupportedError,
PluginNotFoundError,
PluginPermissionDeniedError,
PluginRuntimeError,
PluginUniqueIdentifierError,
)
from core.trigger.errors import (
@@ -375,6 +376,18 @@ class BasePluginClient:
# type `PluginLLMPollingUnsupportedError`.
case PluginLLMPollingUnsupportedError.__name__:
raise PluginLLMPollingUnsupportedError(description=error_object.get("message"))
case PluginRuntimeError.__name__:
args = error_object.get("args")
lambda_request_id = args.get("request_id") if isinstance(args, Mapping) else None
if not isinstance(lambda_request_id, str):
lambda_request_id = None
runtime_message = error_object.get("message")
if not isinstance(runtime_message, str):
runtime_message = "Plugin runtime request failed"
raise PluginRuntimeError(
description=runtime_message,
lambda_request_id=lambda_request_id,
)
case _:
raise PluginInvokeError(description=message)
case PluginDaemonInternalServerError.__name__:
+12
View File
@@ -49,6 +49,18 @@ class PluginDaemonBadRequestError(PluginDaemonClientSideError):
description: str = "Bad Request"
class PluginRuntimeError(PluginDaemonInternalError):
"""A plugin runtime failed before it could return a valid plugin response."""
lambda_request_id: str | None
def __init__(self, description: str, lambda_request_id: str | None = None) -> None:
self.lambda_request_id = lambda_request_id
if lambda_request_id:
description = description.replace(f"RequestId: {lambda_request_id} Error: ", "", 1)
super().__init__(description)
class PluginInvokeError(PluginDaemonClientSideError, ValueError):
description: str = "Invoke Error"
-58
View File
@@ -1,58 +0,0 @@
"""Azure-specific helpers for Redis authentication via Entra ID (Managed Identity)."""
from typing import Union, override
from redis import CredentialProvider
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
class AzureEntraIdCredentialProvider(CredentialProvider):
"""Redis credential provider for Azure Entra ID (Managed Identity) authentication.
Wraps ``redis-entraid``'s provider so that it can be instantiated with no
arguments — required by kombu's URL-based ``credential_provider`` resolution.
"""
_inner: CredentialProvider
def __init__(self) -> None:
from redis_entraid.cred_provider import create_from_default_azure_credential
self._inner = create_from_default_azure_credential(
scopes=(AZURE_REDIS_SCOPE,),
)
@override
def get_credentials(self) -> Union[tuple[str], tuple[str, str]]:
return self._inner.get_credentials()
def get_azure_credential_provider() -> CredentialProvider:
"""Create a redis-py credential provider for Azure Entra ID authentication."""
from redis_entraid.cred_provider import create_from_default_azure_credential
return create_from_default_azure_credential(
scopes=(AZURE_REDIS_SCOPE,),
)
def apply_azure_redis_auth(params: dict) -> None:
"""Apply Azure Entra ID authentication to a Redis connection params dict.
Removes static username/password and injects a credential_provider instead.
"""
params.pop("username", None)
params.pop("password", None)
params["credential_provider"] = get_azure_credential_provider()
def apply_azure_celery_broker_auth(celery_app, broker_url: str) -> None:
"""Configure Celery broker to authenticate via Azure Entra ID credential provider."""
cred_param = "credential_provider=extensions.azure.AzureEntraIdCredentialProvider"
sep = "&" if "?" in broker_url else "?"
broker_url_with_cred = f"{broker_url}{sep}{cred_param}"
celery_app.conf.update(
broker_read_url=broker_url_with_cred,
broker_write_url=broker_url_with_cred,
)
+4 -17
View File
@@ -1,4 +1,3 @@
import logging
import ssl
from datetime import timedelta
from typing import Any
@@ -10,12 +9,9 @@ from typing_extensions import TypedDict
from configs import dify_config
from dify_app import DifyApp
from extensions.azure import AzureEntraIdCredentialProvider, apply_azure_celery_broker_auth
from extensions.redis_names import normalize_redis_key_prefix
from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler
logger = logging.getLogger(__name__)
class _CelerySentinelKwargsDict(TypedDict):
socket_timeout: float | None
@@ -112,12 +108,10 @@ def init_app(app: DifyApp) -> Celery:
broker_transport_options = get_celery_broker_transport_options()
broker_url = dify_config.CELERY_BROKER_URL
celery_app = Celery(
app.name,
task_cls=FlaskTask,
broker=broker_url,
broker=dify_config.CELERY_BROKER_URL,
backend=dify_config.CELERY_BACKEND,
)
@@ -134,12 +128,9 @@ def init_app(app: DifyApp) -> Celery:
)
if dify_config.CELERY_BACKEND == "redis":
redis_backend_conf: dict[str, Any] = {
"result_backend_transport_options": broker_transport_options,
}
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
redis_backend_conf["redis_backend_credential_provider"] = AzureEntraIdCredentialProvider()
celery_app.conf.update(**redis_backend_conf)
celery_app.conf.update(
result_backend_transport_options=broker_transport_options,
)
# Apply SSL configuration if enabled
ssl_options = get_celery_ssl_options()
@@ -150,10 +141,6 @@ def init_app(app: DifyApp) -> Celery:
redis_backend_use_ssl=ssl_options if dify_config.CELERY_BACKEND == "redis" else None,
)
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY and broker_url:
apply_azure_celery_broker_auth(celery_app, broker_url)
logger.info("Celery broker: using Azure Managed Identity (Entra ID) authentication")
if dify_config.LOG_FILE:
celery_app.conf.update(
worker_logfile=dify_config.LOG_FILE,
+1 -14
View File
@@ -21,7 +21,6 @@ from typing_extensions import TypedDict
from configs import dify_config
from dify_app import DifyApp
from extensions.azure import apply_azure_redis_auth, get_azure_credential_provider
from extensions.redis_names import (
normalize_redis_key_prefix,
serialize_redis_name,
@@ -439,10 +438,6 @@ def _create_standalone_client(redis_params: RedisBaseParamsDict) -> Union[redis.
"connection_class": connection_class,
}
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
apply_azure_redis_auth(params)
logger.info("Redis: using Azure Managed Identity (Entra ID) authentication")
if dify_config.REDIS_MAX_CONNECTIONS:
params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS
@@ -462,20 +457,12 @@ def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis |
kwargs: dict[str, Any] = {**health_params}
if max_conns:
kwargs["max_connections"] = max_conns
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
kwargs["credential_provider"] = get_azure_credential_provider()
kwargs["ssl_cert_reqs"] = ssl.CERT_NONE
logger.info("PubSub Redis (cluster): using Azure Managed Identity (Entra ID) authentication")
return RedisCluster.from_url(pubsub_url, **kwargs)
standalone_health_params: dict[str, Any] = dict(_get_connection_health_params())
kwargs = {**standalone_health_params}
if max_conns:
kwargs["max_connections"] = max_conns
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
kwargs["credential_provider"] = get_azure_credential_provider()
kwargs["ssl_cert_reqs"] = ssl.CERT_NONE
logger.info("PubSub Redis: using Azure Managed Identity (Entra ID) authentication")
return redis.Redis.from_url(pubsub_url, **kwargs)
@@ -499,7 +486,7 @@ def init_app(app: DifyApp):
global _pubsub_redis_client
_pubsub_redis_client = client
if dify_config.PUBSUB_REDIS_URL:
if dify_config.normalized_pubsub_redis_url:
_pubsub_redis_client = _create_pubsub_client(
dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
)
+17
View File
@@ -9,6 +9,8 @@ from werkzeug.http import HTTP_STATUS_CODES
from configs import dify_config
from core.errors.error import AppInvokeQuotaExceededError
from core.plugin.impl.exc import PluginRuntimeError
from extensions.ext_logging import get_request_id
from libs.flask_restx_compat import install_swagger_compatibility
from libs.token import build_force_logout_cookie_headers
@@ -100,6 +102,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
return _finalize(e, data, status_code), status_code
def handle_plugin_runtime_error(e: PluginRuntimeError):
got_request_exception.send(current_app, exception=e)
status_code = 502
details = {"request_id": get_request_id()}
if e.lambda_request_id:
details["lambda_request_id"] = e.lambda_request_id
data = {
"code": "plugin_runtime_error",
"message": e.description,
"details": details,
"status": status_code,
}
return _finalize(e, data, status_code), status_code
def handle_general_exception(e: Exception):
got_request_exception.send(current_app, exception=e)
@@ -121,6 +137,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
api.errorhandler(HTTPException)(handle_http_exception)
api.errorhandler(ValueError)(handle_value_error)
api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded)
api.errorhandler(PluginRuntimeError)(handle_plugin_runtime_error)
api.errorhandler(Exception)(handle_general_exception)
+7
View File
@@ -17318,6 +17318,12 @@ Default model entity.
| tool_name | string | | Yes |
| type | string | | Yes |
#### DeploymentEdition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | | |
#### DismissNotificationPayload
| Name | Type | Description | Required |
@@ -22026,6 +22032,7 @@ Model class for provider system configuration response.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean, <br>**Default:** true | | Yes |
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
+7
View File
@@ -1058,6 +1058,12 @@ Button styles for user actions.
| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No |
| name | string | Conversation name. Required when `auto_generate` is `false`. | No |
#### DeploymentEdition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | | |
#### EmailCodeLoginSendPayload
| Name | Type | Description | Required |
@@ -1567,6 +1573,7 @@ Default configuration for form inputs.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean, <br>**Default:** true | | Yes |
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
-1
View File
@@ -21,7 +21,6 @@ dependencies = [
"psycopg2-binary>=2.9.12,<3.0.0",
"python-socketio>=5.13.0,<6.0.0",
"redis[hiredis]>=7.4.0,<8.0.0",
"redis-entraid>=1.2.0,<2.0.0",
"sendgrid>=6.12.5,<7.0.0",
"sseclient-py>=1.8.0,<2.0.0",
# Stable: production-proven, cap below the next major
+17 -1
View File
@@ -75,6 +75,12 @@ class LicenseStatus(StrEnum):
LOST = "lost"
class DeploymentEdition(StrEnum):
COMMUNITY = "COMMUNITY"
ENTERPRISE = "ENTERPRISE"
CLOUD = "CLOUD"
class LicenseModel(FeatureResponseModel):
status: LicenseStatus = LicenseStatus.NONE
expired_at: str = ""
@@ -162,6 +168,7 @@ class PluginManagerModel(FeatureResponseModel):
class SystemFeatureModel(FeatureResponseModel):
deployment_edition: DeploymentEdition
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
@@ -252,7 +259,7 @@ class FeatureService:
@classmethod
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel()
system_features = SystemFeatureModel(deployment_edition=cls._resolve_deployment_edition())
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
@@ -272,6 +279,14 @@ class FeatureService:
return system_features
@classmethod
def _resolve_deployment_edition(cls) -> DeploymentEdition:
if dify_config.EDITION == "CLOUD":
return DeploymentEdition.CLOUD
if dify_config.ENTERPRISE_ENABLED:
return DeploymentEdition.ENTERPRISE
return DeploymentEdition.COMMUNITY
@classmethod
def get_app_dsl_version(cls) -> str:
return CURRENT_APP_DSL_VERSION
@@ -285,6 +300,7 @@ class FeatureService:
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
@@ -414,6 +414,7 @@ class TestFeatureService:
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
mock_config.ENABLE_COLLABORATION_MODE = False
mock_config.ENABLE_CHANGE_EMAIL = True
mock_config.ALLOW_REGISTER = True
mock_config.ALLOW_CREATE_WORKSPACE = True
mock_config.MAIL_TYPE = "smtp"
@@ -616,6 +617,7 @@ class TestFeatureService:
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = True
mock_config.ENABLE_CHANGE_EMAIL = True
mock_config.ALLOW_REGISTER = False
mock_config.ALLOW_CREATE_WORKSPACE = False
mock_config.MAIL_TYPE = None
@@ -19,7 +19,7 @@ from models.engine import db
from models.model import App, AppMode
from services.app_dsl_service import ImportStatus
from services.entities.dsl_entities import CheckDependenciesResult
from services.feature_service import SystemFeatureModel, WebAppAuthModel
from services.feature_service import DeploymentEdition, SystemFeatureModel, WebAppAuthModel
def _unwrap(func):
@@ -47,7 +47,10 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
webapp_auth=WebAppAuthModel(enabled=enabled),
)
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
@@ -11,7 +11,7 @@ from controllers.console.auth.email_register import (
EmailRegisterResetApi,
EmailRegisterSendEmailApi,
)
from services.feature_service import SystemFeatureModel
from services.feature_service import DeploymentEdition, SystemFeatureModel
class TestEmailRegisterSendEmailApi:
@@ -34,7 +34,11 @@ class TestEmailRegisterSendEmailApi:
mock_account = MagicMock()
mock_get_account.return_value = mock_account
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
with (
patch("controllers.console.auth.email_register.dify_config.BILLING_ENABLED", True),
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
@@ -75,7 +79,11 @@ class TestEmailRegisterCheckApi:
mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
@@ -123,7 +131,11 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
@@ -171,7 +183,11 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
@@ -224,7 +240,11 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
@@ -15,7 +15,7 @@ from controllers.console.auth.forgot_password import (
)
from models.account import Account
from models.engine import db
from services.feature_service import SystemFeatureModel
from services.feature_service import DeploymentEdition, SystemFeatureModel
@pytest.fixture
@@ -46,8 +46,15 @@ class TestForgotPasswordSendEmailApi:
mock_get_account.return_value = mock_account
mock_send_email.return_value = "token-123"
wraps_features = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
controller_features = SystemFeatureModel(is_allow_register=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
controller_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=True,
)
with (
patch(
"controllers.console.auth.forgot_password.FeatureService.get_system_features",
@@ -95,7 +102,10 @@ class TestForgotPasswordCheckApi:
mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
wraps_features = SystemFeatureModel(enable_email_password_login=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
@@ -138,7 +148,10 @@ class TestForgotPasswordResetApi:
db.session.commit()
mock_get_account.return_value = account
wraps_features = SystemFeatureModel(enable_email_password_login=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
with (
patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features),
@@ -24,7 +24,7 @@ from controllers.console.auth.forgot_password import (
)
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from models.account import Account, Tenant, TenantAccountJoin
from services.feature_service import SystemFeatureModel
from services.feature_service import DeploymentEdition, SystemFeatureModel
SQLITE_MODELS = (Account, Tenant, TenantAccountJoin)
@@ -48,7 +48,10 @@ def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD")
monkeypatch.setattr(
"controllers.console.wraps.FeatureService.get_system_features",
lambda: SystemFeatureModel(enable_email_password_login=True),
lambda: SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
),
)
@@ -3,7 +3,7 @@ from inspect import unwrap
from pytest_mock import MockerFixture
from models import Account
from services.feature_service import FeatureModel, LimitationModel, SystemFeatureModel
from services.feature_service import DeploymentEdition, FeatureModel, LimitationModel, SystemFeatureModel
def make_account() -> Account:
@@ -94,7 +94,11 @@ class TestSystemFeatureApi:
"controllers.console.feature.current_account_with_tenant_optional",
return_value=(account, "tenant-123"),
)
system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True)
system_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=True,
enable_learn_app=True,
)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
@@ -119,7 +123,10 @@ class TestSystemFeatureApi:
"controllers.console.feature.current_account_with_tenant_optional",
return_value=(None, None),
)
system_features = SystemFeatureModel(is_allow_register=False)
system_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=False,
)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
@@ -1,64 +0,0 @@
import inspect
from unittest.mock import call, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.workspace.plugin_model_providers import (
EnterprisePluginModelProvidersCacheInvalidate,
InvalidatePluginModelProvidersCachePayload,
)
class TestInvalidatePluginModelProvidersCachePayload:
def test_valid_payload(self):
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
)
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
def test_missing_tenant_ids_defaults_to_empty(self):
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
assert payload.tenant_ids == []
def test_unknown_field_rejected(self):
with pytest.raises(ValidationError):
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
class TestEnterprisePluginModelProvidersCacheInvalidate:
@pytest.fixture
def api_instance(self):
return EnterprisePluginModelProvidersCacheInvalidate()
def _post(self, api_instance, app: Flask, payload):
unwrapped_post = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
mock_ns.payload = payload
return unwrapped_post(api_instance)
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
assert result == ({"result": "success"}, 200)
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
call("tenant-alpha"),
call("tenant-beta"),
]
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
result = self._post(api_instance, app, {"tenant_ids": []})
assert result == ({"result": "success"}, 200)
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
result = self._post(api_instance, app, None)
assert result == ({"result": "success"}, 200)
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
@@ -16,7 +16,7 @@ from controllers.web.forgot_password import (
)
from models.account import Account
from models.engine import db
from services.feature_service import SystemFeatureModel
from services.feature_service import DeploymentEdition, SystemFeatureModel
@pytest.fixture
@@ -32,7 +32,10 @@ def database_app() -> Iterator[Flask]:
@pytest.fixture(autouse=True)
def _patch_wraps():
wraps_features = SystemFeatureModel(enable_email_password_login=True)
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
with (
patch("controllers.console.wraps.db") as mock_db,
patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True),
@@ -7,7 +7,7 @@ from pytest_mock import MockerFixture
from core.plugin.endpoint.exc import EndpointSetupFailedError
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError, PluginRuntimeError
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@@ -175,3 +175,25 @@ class TestBasePluginClientImpl:
with pytest.raises(PluginLLMPollingUnsupportedError):
client._handle_plugin_daemon_error("PluginInvokeError", message)
def test_handle_plugin_daemon_error_maps_runtime_error_to_typed_exception(self):
client = BasePluginClient()
lambda_request_id = "45664803-3d3c-4d4f-93fe-e3b19e43092b"
message = json.dumps(
{
"error_type": PluginRuntimeError.__name__,
"message": (
"Plugin runtime request failed: Runtime.ExitError: "
f"RequestId: {lambda_request_id} Error: Runtime exited with error: exit status 1"
),
"args": {"request_id": lambda_request_id, "status_code": 200},
}
)
with pytest.raises(PluginRuntimeError) as exc_info:
client._handle_plugin_daemon_error("PluginInvokeError", message)
assert exc_info.value.description == (
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1"
)
assert exc_info.value.lambda_request_id == lambda_request_id
@@ -1,186 +0,0 @@
"""Tests for Azure Managed Identity Redis helpers."""
from unittest.mock import MagicMock, patch
import pytest
from configs import DifyConfig
from configs.middleware.cache.redis_config import RedisConfig
from extensions.azure import (
AzureEntraIdCredentialProvider,
apply_azure_celery_broker_auth,
apply_azure_redis_auth,
get_azure_credential_provider,
)
class TestAzureConfigValidation:
"""Test pydantic config validation for Azure Managed Redis constraints."""
def test_redis_db_0_passes(self):
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True, REDIS_DB=0)
assert config.REDIS_DB == 0
def test_redis_db_nonzero_raises(self):
with pytest.raises(ValueError, match="only supports db 0"):
RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True, REDIS_DB=1)
def test_redis_db_nonzero_without_azure_mi_passes(self):
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=False, REDIS_DB=5)
assert config.REDIS_DB == 5
def test_redis_db_default_with_azure_mi_passes(self):
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True)
assert config.REDIS_DB == 0
def test_celery_broker_url_db_0_with_azure_mi_passes(self):
config = DifyConfig(
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
REDIS_DB=0,
CELERY_BROKER_URL="rediss://:@host:10000/0",
)
assert config.CELERY_BROKER_URL == "rediss://:@host:10000/0"
def test_celery_broker_url_nonzero_db_with_azure_mi_raises(self):
with pytest.raises(ValueError, match="only supports db 0"):
DifyConfig(
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
REDIS_DB=0,
CELERY_BROKER_URL="rediss://:@host:10000/1",
)
def test_celery_broker_url_nonzero_db_without_azure_mi_passes(self):
config = DifyConfig(
REDIS_USE_AZURE_MANAGED_IDENTITY=False,
CELERY_BROKER_URL="redis://localhost:6379/5",
)
assert config.CELERY_BROKER_URL == "redis://localhost:6379/5"
def test_pubsub_redis_url_db_0_with_azure_mi_passes(self):
config = DifyConfig(
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
REDIS_DB=0,
PUBSUB_REDIS_URL="rediss://:@host:10000/0",
)
assert config.PUBSUB_REDIS_URL == "rediss://:@host:10000/0"
def test_pubsub_redis_url_nonzero_db_with_azure_mi_raises(self):
with pytest.raises(ValueError, match="only supports db 0"):
DifyConfig(
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
REDIS_DB=0,
PUBSUB_REDIS_URL="rediss://:@host:10000/2",
)
def test_pubsub_redis_url_nonzero_db_without_azure_mi_passes(self):
config = DifyConfig(
REDIS_USE_AZURE_MANAGED_IDENTITY=False,
PUBSUB_REDIS_URL="redis://localhost:6379/3",
)
assert config.PUBSUB_REDIS_URL == "redis://localhost:6379/3"
class TestApplyAzureCeleryBrokerAuth:
"""Test apply_azure_celery_broker_auth Celery configuration."""
def test_sets_broker_read_and_write_urls(self):
mock_app = MagicMock()
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
mock_app.conf.update.assert_called_once()
call_kwargs = mock_app.conf.update.call_args[1]
assert "broker_read_url" in call_kwargs
assert "broker_write_url" in call_kwargs
def test_appends_credential_provider_query_param(self):
mock_app = MagicMock()
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
call_kwargs = mock_app.conf.update.call_args[1]
expected_param = "credential_provider=extensions.azure.AzureEntraIdCredentialProvider"
assert expected_param in call_kwargs["broker_read_url"]
assert expected_param in call_kwargs["broker_write_url"]
def test_uses_ampersand_when_url_already_has_query(self):
mock_app = MagicMock()
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0?timeout=5")
call_kwargs = mock_app.conf.update.call_args[1]
assert "&credential_provider=" in call_kwargs["broker_read_url"]
def test_uses_question_mark_when_url_has_no_query(self):
mock_app = MagicMock()
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
call_kwargs = mock_app.conf.update.call_args[1]
assert "?credential_provider=" in call_kwargs["broker_read_url"]
class TestAzureEntraIdCredentialProvider:
"""Test AzureEntraIdCredentialProvider wrapper."""
def test_get_credentials_delegates_to_inner(self):
provider = AzureEntraIdCredentialProvider.__new__(AzureEntraIdCredentialProvider)
mock_inner = MagicMock()
mock_inner.get_credentials.return_value = ("user-oid", "jwt-token")
provider._inner = mock_inner
result = provider.get_credentials()
assert result == ("user-oid", "jwt-token")
class TestGetAzureCredentialProvider:
"""Test get_azure_credential_provider factory."""
@patch("redis_entraid.cred_provider.create_from_default_azure_credential")
def test_calls_create_with_correct_scope(self, mock_create):
mock_create.return_value = MagicMock()
get_azure_credential_provider()
mock_create.assert_called_once_with(
scopes=("https://redis.azure.com/.default",),
)
@patch("redis_entraid.cred_provider.create_from_default_azure_credential")
def test_returns_provider_instance(self, mock_create):
sentinel = MagicMock()
mock_create.return_value = sentinel
result = get_azure_credential_provider()
assert result is sentinel
class TestApplyAzureRedisAuth:
"""Test apply_azure_redis_auth params mutation."""
@patch("extensions.azure.get_azure_credential_provider")
def test_removes_username_and_password(self, mock_get_provider):
mock_get_provider.return_value = MagicMock()
params: dict = {"host": "localhost", "username": "user", "password": "secret"}
apply_azure_redis_auth(params)
assert "username" not in params
assert "password" not in params
@patch("extensions.azure.get_azure_credential_provider")
def test_injects_credential_provider(self, mock_get_provider):
sentinel = MagicMock()
mock_get_provider.return_value = sentinel
params: dict = {"host": "localhost", "username": "u", "password": "p"}
apply_azure_redis_auth(params)
assert params["credential_provider"] is sentinel
@patch("extensions.azure.get_azure_credential_provider")
def test_handles_missing_username_password(self, mock_get_provider):
mock_get_provider.return_value = MagicMock()
params: dict = {"host": "localhost"}
apply_azure_redis_auth(params)
assert "username" not in params
assert "password" not in params
assert "credential_provider" in params
@@ -4,6 +4,7 @@ from werkzeug.exceptions import BadRequest, Unauthorized
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
from core.errors.error import AppInvokeQuotaExceededError
from core.plugin.impl.exc import PluginRuntimeError
from libs.exception import BaseHTTPException
from libs.external_api import ExternalApi
from libs.rate_limit import _BearerRateLimited
@@ -39,6 +40,14 @@ def _create_api_app():
def get(self):
raise RuntimeError("oops")
@api.route("/plugin-runtime-error")
class PluginRuntime(Resource):
def get(self):
raise PluginRuntimeError(
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
lambda_request_id="lambda-request-id",
)
# Note: We avoid altering default_mediatype to keep normal error paths
# Special 400 message rewrite
@@ -107,6 +116,24 @@ def test_external_api_json_message_and_bad_request_rewrite():
assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty."
def test_external_api_plugin_runtime_error(mocker):
mocker.patch("libs.external_api.get_request_id", return_value="api-request-id")
app = _create_api_app()
res = app.test_client().get("/api/plugin-runtime-error")
assert res.status_code == 502
assert res.get_json() == {
"code": "plugin_runtime_error",
"message": "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
"details": {
"request_id": "api-request-id",
"lambda_request_id": "lambda-request-id",
},
"status": 502,
}
def test_external_api_param_mapping_and_quota():
app = _create_api_app()
client = app.test_client()
@@ -24,6 +24,7 @@ from models.engine import db
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import (
DeploymentEdition,
PluginInstallationPermissionModel,
PluginInstallationScope,
SystemFeatureModel,
@@ -35,10 +36,11 @@ def _make_features(
scope: PluginInstallationScope = PluginInstallationScope.ALL,
) -> SystemFeatureModel:
return SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
plugin_installation_permission=PluginInstallationPermissionModel(
restrict_to_marketplace_only=restrict_to_marketplace,
plugin_installation_scope=scope,
)
),
)
@@ -0,0 +1,25 @@
import pytest
from services.feature_service import FeatureService
@pytest.mark.parametrize("enabled", [False, True])
def test_get_system_features_reads_enable_change_email(
monkeypatch: pytest.MonkeyPatch,
enabled: bool,
) -> None:
monkeypatch.setattr("services.feature_service.dify_config.ENABLE_CHANGE_EMAIL", enabled)
result = FeatureService.get_system_features()
assert result.enable_change_email is enabled
def test_enterprise_disables_change_email(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.feature_service.dify_config.ENABLE_CHANGE_EMAIL", True)
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True)
monkeypatch.setattr("services.feature_service.FeatureService._fulfill_params_from_enterprise", lambda *_: None)
result = FeatureService.get_system_features()
assert result.enable_change_email is False
@@ -0,0 +1,34 @@
import pytest
from pydantic import ValidationError
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
def test_system_feature_model_requires_deployment_edition() -> None:
with pytest.raises(ValidationError):
SystemFeatureModel.model_validate({})
@pytest.mark.parametrize(
("edition", "enterprise_enabled", "expected"),
[
("SELF_HOSTED", False, DeploymentEdition.COMMUNITY),
("SELF_HOSTED", True, DeploymentEdition.ENTERPRISE),
("CLOUD", False, DeploymentEdition.CLOUD),
("CLOUD", True, DeploymentEdition.CLOUD),
],
)
def test_get_system_features_resolves_deployment_edition(
monkeypatch: pytest.MonkeyPatch,
edition: str,
enterprise_enabled: bool,
expected: DeploymentEdition,
) -> None:
monkeypatch.setattr("services.feature_service.dify_config.EDITION", edition)
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", enterprise_enabled)
monkeypatch.setattr("services.feature_service.FeatureService._fulfill_params_from_enterprise", lambda *_: None)
result = FeatureService.get_system_features()
assert result.deployment_edition is expected
assert result.model_dump(mode="json")["deployment_edition"] == expected.value
@@ -1,7 +1,7 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
@pytest.mark.parametrize(
@@ -29,7 +29,7 @@ def test_fulfill_params_from_enterprise_enable_app_deploy(
staticmethod(lambda: enterprise_info),
)
features = SystemFeatureModel()
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
features.enable_app_deploy = initial
FeatureService._fulfill_params_from_enterprise(features)
@@ -1,10 +1,10 @@
import pytest
from services.feature_service import FeatureService, SystemFeatureModel
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
def test_system_feature_model_disables_knowledge_fs_by_default() -> None:
assert SystemFeatureModel().knowledge_fs_enabled is False
assert SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY).knowledge_fs_enabled is False
@pytest.mark.parametrize("enabled", [False, True])
@@ -1,12 +1,14 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
def test_system_feature_model_defaults_enable_learn_app():
assert SystemFeatureModel().enable_learn_app is True
assert SystemFeatureModel().enable_step_by_step_tour is False
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
assert system_features.enable_learn_app is True
assert system_features.enable_step_by_step_tour is False
@pytest.mark.parametrize("enabled", [True, False])
@@ -1,7 +1,7 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
_ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}}
@@ -14,7 +14,7 @@ def test_fulfill_params_from_enterprise_parses_licensed_seats(monkeypatch: pytes
staticmethod(lambda: _ENTERPRISE_INFO),
)
features = SystemFeatureModel()
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
FeatureService._fulfill_params_from_enterprise(features, is_authenticated=True)
assert features.license.seats.enabled is True
@@ -30,7 +30,7 @@ def test_fulfill_params_from_enterprise_withholds_seats_when_unauthenticated(mon
staticmethod(lambda: _ENTERPRISE_INFO),
)
features = SystemFeatureModel()
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
FeatureService._fulfill_params_from_enterprise(features, is_authenticated=False)
assert features.license.seats.enabled is False
@@ -1,6 +1,6 @@
import pytest
from services.feature_service import FeatureService, SystemFeatureModel
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
@pytest.mark.parametrize(
@@ -18,7 +18,7 @@ def test_fulfill_system_params_from_env_sets_allow_public_access(
):
monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value)
system_features = SystemFeatureModel()
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
FeatureService._fulfill_system_params_from_env(system_features)
assert system_features.webapp_auth.allow_public_access is expected
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services import recommended_app_service as service_module
from services.feature_service import SystemFeatureModel
from services.feature_service import DeploymentEdition, SystemFeatureModel
from services.recommended_app_service import RecommendedAppService
pytestmark = pytest.mark.parametrize(
@@ -298,7 +298,10 @@ class TestRecommendedAppServiceGetDetail:
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
cases: list[tuple[str, RecommendedAppPayload]] = [
(
"complex-app",
@@ -337,7 +340,10 @@ class TestRecommendedAppServiceGetDetail:
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
for mode in ["remote", "builtin", "db"]:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode
detail = _app_detail(app_id="test-app", name=f"App from {mode}")
@@ -367,7 +373,10 @@ class TestRecommendedAppServiceGetLearnDifyApps:
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_instance = MagicMock()
mock_instance.get_learn_dify_apps.return_value = {
@@ -402,7 +411,12 @@ class TestRecommendedAppServiceGetLearnDifyApps:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
can_trial_mock = MagicMock(return_value=True)
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
@@ -425,7 +439,12 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=False)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
),
)
result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)
@@ -456,7 +475,12 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session)
@@ -492,7 +516,12 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
)
result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
Generated
-18
View File
@@ -1371,7 +1371,6 @@ dependencies = [
{ name = "python-socketio" },
{ name = "readabilipy" },
{ name = "redis", extra = ["hiredis"] },
{ name = "redis-entraid" },
{ name = "resend" },
{ name = "sendgrid" },
{ name = "sseclient-py" },
@@ -1656,7 +1655,6 @@ requires-dist = [
{ name = "python-socketio", specifier = ">=5.13.0,<6.0.0" },
{ name = "readabilipy", specifier = "==0.3.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" },
{ name = "redis-entraid", specifier = ">=1.2.0,<2.0.0" },
{ name = "resend", specifier = ">=2.27.0,<3.0.0" },
{ name = "sendgrid", specifier = ">=6.12.5,<7.0.0" },
{ name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" },
@@ -5821,22 +5819,6 @@ hiredis = [
{ name = "hiredis" },
]
[[package]]
name = "redis-entraid"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-identity" },
{ name = "msal" },
{ name = "pyjwt" },
{ name = "redis" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/a7/0ddaeb27b33c76709e05a12b3bbeefce893c82a3a830146608d6fe620000/redis_entraid-1.2.1.tar.gz", hash = "sha256:a7c479ce46e6edb35bce9dd804d1cad7be99a3330815cfe028a648b486a10b41", size = 9792, upload-time = "2026-06-03T11:38:55.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/ca/01b8607102de756b270d3f6befeee700bd82dace4303d6f47ce0f53c11b0/redis_entraid-1.2.1-py3-none-any.whl", hash = "sha256:9de7e4a716b156d966a2d6bb5b5ccd64a692db30ae21fe3987f57d233793d558", size = 7967, upload-time = "2026-06-03T11:38:54.497Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
-1
View File
@@ -121,7 +121,6 @@ REDIS_KEEPALIVE=true
REDIS_KEEPALIVE_IDLE=30
REDIS_KEEPALIVE_INTERVAL=10
REDIS_KEEPALIVE_COUNT=10
REDIS_USE_AZURE_MANAGED_IDENTITY=false
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
CELERY_BACKEND=redis
BROKER_USE_SSL=false
+14 -2
View File
@@ -16,11 +16,21 @@ CHECK_UPDATE_URL=https://updates.dify.ai
OPENAI_API_BASE=https://api.openai.com/v1
MIGRATION_ENABLED=true
FILES_ACCESS_TIMEOUT=300
# System Features
MARKETPLACE_ENABLED=true
ENABLE_EMAIL_CODE_LOGIN=false
ENABLE_EMAIL_PASSWORD_LOGIN=true
ENABLE_SOCIAL_OAUTH_LOGIN=false
# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service.
ENABLE_COLLABORATION_MODE=true
# Learn app feature toggle
ALLOW_REGISTER=false
ALLOW_CREATE_WORKSPACE=false
ENABLE_CHANGE_EMAIL=true
ENABLE_TRIAL_APP=false
ENABLE_EXPLORE_BANNER=false
ENABLE_LEARN_APP=true
ENABLE_STEP_BY_STEP_TOUR=false
RBAC_ENABLED=false
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
CELERY_TASK_ANNOTATIONS=null
AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net
@@ -90,6 +100,8 @@ WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
DEPLOY_ENV=PRODUCTION
EDITION=SELF_HOSTED
ENTERPRISE_ENABLED=false
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
APP_DEFAULT_ACTIVE_REQUESTS=0
@@ -15,7 +15,6 @@ TEXT_GENERATION_TIMEOUT_MS=60000
ALLOW_INLINE_STYLES=false
ALLOW_UNSAFE_DATA_SCHEME=false
MAX_TREE_DEPTH=50
MARKETPLACE_ENABLED=true
MARKETPLACE_API_URL=https://marketplace.dify.ai
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
ALLOW_EMBED=false
-8
View File
@@ -835,14 +835,6 @@
"count": 1
}
},
"web/app/components/base/audio-btn/audio.ts": {
"node-js/prefer-global/buffer": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 3
}
},
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
"jsx_a11y/media-has-caption": {
"count": 1
@@ -6,6 +6,7 @@ export type ClientOptions = {
export type SystemFeatureModel = {
branding: BrandingModel
deployment_edition: DeploymentEdition
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
@@ -40,6 +41,8 @@ export type BrandingModel = {
workspace_logo: string
}
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
export type LicenseModel = {
expired_at: string
seats: LicenseLimitationModel
@@ -13,6 +13,11 @@ export const zBrandingModel = z.object({
workspace_logo: z.string().default(''),
})
/**
* DeploymentEdition
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* PluginManagerModel
*/
@@ -104,6 +109,7 @@ export const zSystemFeatureModel = z.object({
login_page_logo: '',
workspace_logo: '',
}),
deployment_edition: zDeploymentEdition,
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
@@ -118,6 +118,8 @@ export type ConversationRenamePayload = (
name?: string | null
}
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
export type EmailCodeLoginSendPayload = {
email: string
language?: string | null
@@ -510,6 +512,7 @@ export type SuggestedQuestionsResponse = {
export type SystemFeatureModel = {
branding: BrandingModel
deployment_edition: DeploymentEdition
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
@@ -132,6 +132,11 @@ export const zConversationRenamePayload = z.intersection(
}),
)
/**
* DeploymentEdition
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* EmailCodeLoginSendPayload
*/
@@ -782,6 +787,7 @@ export const zSystemFeatureModel = z.object({
login_page_logo: '',
workspace_logo: '',
}),
deployment_edition: zDeploymentEdition,
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
-21
View File
@@ -1,9 +1,5 @@
# For production release, change this to PRODUCTION
NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
# The deployment edition, SELF_HOSTED
NEXT_PUBLIC_EDITION=SELF_HOSTED
# Whether a self-hosted deployment runs Enterprise Edition
NEXT_PUBLIC_ENTERPRISE_ENABLED=false
# The base path for the application
NEXT_PUBLIC_BASE_PATH=
# Server-only console API origin for server-side requests.
@@ -114,20 +110,3 @@ NEXT_PUBLIC_WEB_PREFIX=
# number of concurrency
NEXT_PUBLIC_BATCH_CONCURRENCY=5
# Cloud system-features frontend defaults.
# These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION).
NEXT_PUBLIC_ENABLE_MARKETPLACE=true
NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN=true
NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN=false
NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN=true
NEXT_PUBLIC_ENABLE_COLLABORATION_MODE=false
NEXT_PUBLIC_ALLOW_REGISTER=true
NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE=true
NEXT_PUBLIC_IS_EMAIL_SETUP=true
NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=true
NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=true
NEXT_PUBLIC_ENABLE_TRIAL_APP=true
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=true
NEXT_PUBLIC_RBAC_ENABLED=false
NEXT_PUBLIC_KNOWLEDGE_FS_ENABLED=false
-1
View File
@@ -49,7 +49,6 @@ RUN pnpm build && pnpm build:vinext
FROM base AS production
ENV NODE_ENV=production
ENV EDITION=SELF_HOSTED
ENV DEPLOY_ENV=PRODUCTION
ENV CONSOLE_API_URL=http://127.0.0.1:5001
ENV APP_API_URL=http://127.0.0.1:5001
@@ -1,3 +1,5 @@
import type { RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -14,21 +16,21 @@ import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-lim
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import { render } from '@/test/console/render'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
const render = (ui: ReactElement, options: RenderOptions = {}) => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
})
return renderWithConsoleState(ui, { ...options, wrapper })
}
let mockProviderCtx: Record<string, unknown> = {}
let mockConsoleState: Record<string, unknown> = {}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
}))
@@ -1,3 +1,5 @@
import type { RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
/**
* Integration test: Education Verification Flow
*
@@ -14,7 +16,15 @@ import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import PlanComp from '@/app/components/billing/plan'
import { Plan } from '@/app/components/billing/type'
import { render } from '@/test/console/render'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
const render = (ui: ReactElement, options: RenderOptions = {}) => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
})
return renderWithConsoleState(ui, { ...options, wrapper })
}
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
@@ -25,14 +35,6 @@ const mockRouterPush = vi.fn()
const mockMutateAsync = vi.fn()
const mockSetEducationVerifying = vi.hoisted(() => vi.fn())
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
@@ -7,11 +7,15 @@
* Covers URL param reading, cookie persistence, API bind on mount,
* cookie cleanup after successful bind, and error handling for 400 status.
*/
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import * as React from 'react'
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
import { PARTNER_STACK_CONFIG } from '@/config'
import { renderWithConsoleQuery } from '@/test/console/query-data'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockSearchParams = new URLSearchParams()
@@ -39,7 +43,6 @@ vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
IS_CLOUD_EDITION: true,
PARTNER_STACK_CONFIG: {
cookieName: 'partner_stack_info',
saveCookieDays: 90,
@@ -288,7 +291,7 @@ describe('Partner Stack Flow', () => {
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
describe('PartnerStack component mount behavior', () => {
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
it('should call saveOrUpdate and bind on mount', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'mount-partner',
ps_xid: 'mount-click',
@@ -299,18 +302,13 @@ describe('Partner Stack Flow', () => {
render(<PartnerStack />)
// The component calls saveOrUpdate and bind in useEffect
await waitFor(() => {
// Bind should have been called
expect(mockMutateAsync).toHaveBeenCalledWith({
partnerKey: 'mount-partner',
clickId: 'mount-click',
})
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
})
// Cookie should have been saved (saveOrUpdate was called before bind)
// After bind succeeds, cookie is removed
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
})
it('should render nothing (return null)', async () => {
-28
View File
@@ -1,6 +1,5 @@
describe('env runtime transport', () => {
const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
const originalRbacEnv = process.env.NEXT_PUBLIC_RBAC_ENABLED
beforeEach(() => {
vi.clearAllMocks()
@@ -8,17 +7,12 @@ describe('env runtime transport', () => {
vi.doUnmock('../utils/client')
document.body.removeAttribute('data-enable-agent-v2')
document.body.removeAttribute('data-enable-agent-v-2')
document.body.removeAttribute('data-rbac-enabled')
delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
delete process.env.NEXT_PUBLIC_RBAC_ENABLED
})
afterAll(() => {
if (originalAgentV2Env === undefined) delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env
if (originalRbacEnv === undefined) delete process.env.NEXT_PUBLIC_RBAC_ENABLED
else process.env.NEXT_PUBLIC_RBAC_ENABLED = originalRbacEnv
})
it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => {
@@ -29,14 +23,6 @@ describe('env runtime transport', () => {
expect(env.NEXT_PUBLIC_ENABLE_AGENT_V2).toBe(true)
})
it('should read NEXT_PUBLIC_RBAC_ENABLED from the browser runtime dataset key', async () => {
document.body.setAttribute('data-rbac-enabled', 'true')
const { env } = await import('../env')
expect(env.NEXT_PUBLIC_RBAC_ENABLED).toBe(true)
})
it('should emit the Agent v2 runtime dataset attribute from getDatasetMap on the server', async () => {
process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = 'true'
@@ -51,18 +37,4 @@ describe('env runtime transport', () => {
expect(datasetMap['data-enable-agent-v2']).toBe(true)
expect(datasetMap['data-enable-agent-v-2']).toBeUndefined()
})
it('should emit the RBAC runtime dataset attribute from getDatasetMap on the server', async () => {
process.env.NEXT_PUBLIC_RBAC_ENABLED = 'true'
vi.doMock('../utils/client', () => ({
isClient: false,
isServer: true,
}))
const { getDatasetMap } = await import('../env')
const datasetMap = getDatasetMap()
expect(datasetMap['data-rbac-enabled']).toBe(true)
})
})
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import { ConsoleBootstrapGate } from '../console-bootstrap-gate'
const profileQueryKey = ['console', 'account', 'profile', 'get']
@@ -26,7 +27,9 @@ const createDeferred = <T,>(): Deferred<T> => {
const mocks = vi.hoisted(() => ({
profileQuery: undefined as Deferred<{ id: string }> | undefined,
systemFeaturesQuery: undefined as Deferred<{ branding: { enabled: boolean } }> | undefined,
systemFeaturesQuery: undefined as
| Deferred<ReturnType<typeof createSystemFeaturesFixture>>
| undefined,
}))
vi.mock('@/features/account-profile/client', () => ({
@@ -36,12 +39,24 @@ vi.mock('@/features/account-profile/client', () => ({
}),
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: systemFeaturesQueryKey,
queryFn: () => mocks.systemFeaturesQuery!.promise,
}),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
return {
...actual,
consoleQuery: {
...actual.consoleQuery,
systemFeatures: {
get: {
...actual.consoleQuery.systemFeatures.get,
queryOptions: () => ({
queryKey: systemFeaturesQueryKey,
queryFn: () => mocks.systemFeaturesQuery!.promise,
}),
},
},
},
}
})
function createQueryClient() {
return new QueryClient({
@@ -78,7 +93,7 @@ describe('ConsoleBootstrapGate', () => {
expect(screen.queryByText('Console shell')).not.toBeInTheDocument()
await act(async () => {
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
mocks.systemFeaturesQuery!.resolve(createSystemFeaturesFixture())
})
expect(await screen.findByText('Console shell')).toBeInTheDocument()
@@ -87,11 +102,9 @@ describe('ConsoleBootstrapGate', () => {
it('keeps atom consumers mounted when a cached profile background refetch fails', async () => {
const queryClient = createQueryClient()
queryClient.setQueryData(profileQueryKey, { id: 'user-1' }, { updatedAt: 1 })
queryClient.setQueryData(
systemFeaturesQueryKey,
{ branding: { enabled: false } },
{ updatedAt: 1 },
)
queryClient.setQueryData(systemFeaturesQueryKey, createSystemFeaturesFixture(), {
updatedAt: 1,
})
renderGate(<div>Console shell</div>, queryClient)
@@ -102,7 +115,7 @@ describe('ConsoleBootstrapGate', () => {
await act(async () => {
mocks.profileQuery!.reject(new Error('profile refetch failed'))
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
mocks.systemFeaturesQuery!.resolve(createSystemFeaturesFixture())
})
await waitFor(() => {
@@ -1,10 +1,11 @@
import type { DehydratedState } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
queryClient: undefined as QueryClient | undefined,
rootQueryClient: undefined as QueryClient | undefined,
profileQueryFn: vi.fn(),
systemFeaturesQueryFn: vi.fn(),
workspaceQueryFn: vi.fn(),
@@ -18,9 +19,14 @@ const mocks = vi.hoisted(() => ({
basePath: '',
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => mocks.queryClient,
}))
vi.mock('@/context/query-client-server', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/query-client-server')>()
return {
...actual,
getQueryClientServer: () => mocks.rootQueryClient,
}
})
vi.mock('@/next/headers', () => ({
headers: () => mocks.headers(),
@@ -44,6 +50,14 @@ vi.mock('@/features/account-profile/server', () => ({
}),
}))
vi.mock('@/features/system-features/server', () => ({
serverSystemFeaturesQueryOptions: () => ({
queryKey: ['console', 'system-features'],
queryFn: mocks.systemFeaturesQueryFn,
retry: false,
}),
}))
vi.mock('@/service/server', () => ({
getServerConsoleClientContext: () => mocks.getServerConsoleClientContext(),
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
@@ -58,19 +72,11 @@ vi.mock('@/service/server', () => ({
},
}))
vi.mock('@/features/system-features/server', () => ({
serverSystemFeaturesQueryOptions: () => ({
queryKey: ['console', 'system-features'],
queryFn: mocks.systemFeaturesQueryFn,
retry: false,
}),
}))
describe('CommonLayoutHydrationBoundary', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.basePath = ''
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.rootQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.headers.mockResolvedValue(
new Headers({
'x-dify-pathname': '/apps',
@@ -107,7 +113,7 @@ describe('CommonLayoutHydrationBoundary', () => {
})
})
it('should prefetch common layout queries and render children', async () => {
it('should prefetch common layout queries without requesting System Features', async () => {
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
@@ -121,7 +127,7 @@ describe('CommonLayoutHydrationBoundary', () => {
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
expect(mocks.getServerConsoleClientContext).toHaveBeenCalledTimes(1)
expect(mocks.workspaceQueryOptions).toHaveBeenCalledWith({
context: {
@@ -133,6 +139,25 @@ describe('CommonLayoutHydrationBoundary', () => {
expect(mocks.workspaceQueryFn).toHaveBeenCalledTimes(1)
})
it('should dehydrate only Common-owned queries', async () => {
mocks.rootQueryClient?.setQueryData(['console', 'system-features'], {
deployment_edition: 'CLOUD',
})
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({ children: null })
const state = (element as ReactElement<{ state: DehydratedState }>).props.state
const queryKeys = state.queries.map((query) => query.queryKey)
expect(queryKeys).toHaveLength(2)
expect(queryKeys).toEqual(
expect.arrayContaining([
['common', 'user-profile'],
['console', 'workspaces', 'current', 'post'],
]),
)
})
it('should redirect unauthorized users to the refresh route with the current path', async () => {
mocks.basePath = '/workflow'
mocks.profileQueryFn.mockRejectedValue(
@@ -1,5 +1,6 @@
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { screen } from '@testing-library/react'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppACLPermission } from '@/utils/permission'
import ChartView from '../chart-view'
@@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({
currentUserId: 'user-1',
workspacePermissionKeys: [] as string[],
chartRenderSpy: vi.fn(),
conversationPeriodSpy: vi.fn(),
}))
vi.mock('@/context/workspace-state', async () => {
@@ -46,8 +48,9 @@ vi.mock('@/app/components/app/overview/app-chart', () => ({
testState.chartRenderSpy('avg-user-interactions')
return <div>avg user interactions chart</div>
},
ConversationsChart: () => {
ConversationsChart: ({ period }: { period: PeriodParams }) => {
testState.chartRenderSpy('conversations')
testState.conversationPeriodSpy(period)
return <div>conversations chart</div>
},
CostChart: () => {
@@ -1,6 +1,8 @@
'use client'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import { useAtomValue } from 'jotai'
@@ -23,10 +25,10 @@ import {
WorkflowMessagesChart,
} from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import { IS_CLOUD_EDITION } from '@/config'
import { userProfileIdAtom } from '@/context/account-state'
import { useDocLink } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getAppACLCapabilities } from '@/utils/permission'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
@@ -51,7 +53,28 @@ type IChartViewProps = {
}
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
return (
<ChartViewContent
appId={appId}
headerRight={headerRight}
deploymentEdition={deploymentEdition}
/>
)
}
function ChartViewContent({
appId,
headerRight,
deploymentEdition,
}: IChartViewProps & { deploymentEdition: DeploymentEdition }) {
const { t } = useTranslation()
const isCloudEdition = deploymentEdition === 'CLOUD'
const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE'
const docLink = useDocLink()
const appDetail = useAppStore((state) => state.appDetail)
const currentUserId = useAtomValue(userProfileIdAtom)
@@ -67,8 +90,8 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(
IS_CLOUD_EDITION
const [period, setPeriod] = useState<PeriodParams>(() =>
isCloudEdition
? {
name: t(($) => $['filter.period.today'], { ns: 'appLog' }),
query: {
@@ -112,13 +135,14 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
</div>
</div>
<div className="mt-1 flex h-10 items-center justify-between pr-10 pl-6">
{IS_CLOUD_EDITION ? (
{isCloudEdition && (
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
) : (
)}
{isNonCloudEdition && (
<LongTimeRangePicker
periodMapping={LONG_TIME_PERIOD_MAPPING}
onSelect={setPeriod}
@@ -43,14 +43,6 @@ vi.mock('@/context/permission-state', async () => {
workspacePermissionKeys: [],
}))
})
vi.mock('@/context/system-features-state', async () => {
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
return createSystemFeaturesStateModuleMock(() => ({
datasetRbacEnabled: mockIsRbacEnabled,
}))
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: undefined,
@@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useEffect } from 'react'
@@ -13,8 +14,8 @@ import {
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
} from '@/context/permission-state'
import { datasetRbacEnabledAtom } from '@/context/system-features-state'
import { currentWorkspaceLoadingAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
@@ -60,7 +61,10 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom)
const { data: isRbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ rbac_enabled }) => rbac_enabled,
})
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
@@ -149,7 +149,7 @@ describe('DatasetsLayout', () => {
expect(mockReplace).not.toHaveBeenCalled()
})
it.each(['/datasets/create', '/datasets/create-from-pipeline'])(
it.each(['/datasets/create', '/datasets/create-from-pipeline', '/datasets/new/create'])(
'should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s',
async (pathname) => {
mockPathname = pathname
@@ -186,6 +186,22 @@ describe('DatasetsLayout', () => {
expect(mockReplace).not.toHaveBeenCalled()
})
it('should render direct new knowledge creation route when workspace has dataset.create_and_management', () => {
mockPathname = '/datasets/new/create'
setConsoleState({
workspacePermissionKeys: ['dataset.create_and_management'],
})
render(
<DatasetsLayout>
<div>datasets</div>
</DatasetsLayout>,
)
expect(screen.getByText('datasets')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect direct external dataset connection route to /datasets without dataset.external.connect', async () => {
mockPathname = '/datasets/connect'
setConsoleState({
@@ -17,6 +17,7 @@ const isDatasetCreatePath = (pathname: string) => {
return (
pathname === '/datasets/create' ||
pathname.startsWith('/datasets/create/') ||
pathname === '/datasets/new/create' ||
pathname === '/datasets/create-from-pipeline' ||
pathname.startsWith('/datasets/create-from-pipeline/')
)
@@ -0,0 +1,5 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="documents" />
}
@@ -0,0 +1,18 @@
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
import { KnowledgeSpaceShell } from '@/features/new-rag/knowledge-space-shell'
export default async function Layout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ knowledgeSpaceId: string }>
}) {
const { knowledgeSpaceId } = await params
return (
<KnowledgeRouteGuard>
<KnowledgeSpaceShell knowledgeSpaceId={knowledgeSpaceId}>{children}</KnowledgeSpaceShell>
</KnowledgeRouteGuard>
)
}
@@ -0,0 +1,6 @@
import { redirect } from '@/next/navigation'
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
const { knowledgeSpaceId } = await params
redirect(`/datasets/new/${knowledgeSpaceId}/sources`)
}
@@ -0,0 +1,5 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="sources" />
}
@@ -0,0 +1,10 @@
import { CreateKnowledgePage } from '@/features/new-rag/create-knowledge-page'
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
export default function Page() {
return (
<KnowledgeRouteGuard>
<CreateKnowledgePage />
</KnowledgeRouteGuard>
)
}
+10 -1
View File
@@ -1,6 +1,7 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import { isLegacyBase401 } from '@/features/account-profile/client'
@@ -12,6 +13,7 @@ type Props = Readonly<{
export default function CommonLayoutError({ error, unstable_retry }: Props) {
const { t } = useTranslation('common')
const { reset } = useQueryErrorResetBoundary()
console.error(error)
@@ -26,7 +28,14 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
<div className="system-sm-regular text-text-tertiary">
{t(($) => $['errorBoundary.message'])}
</div>
<Button size="small" variant="secondary" onClick={() => unstable_retry()}>
<Button
size="small"
variant="secondary"
onClick={() => {
reset()
unstable_retry()
}}
>
{t(($) => $['errorBoundary.tryAgain'])}
</Button>
</div>
@@ -1,8 +1,7 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClientServer } from '@/context/query-client-server'
import { makeQueryClient } from '@/context/query-client-server'
import { serverUserProfileQueryOptions } from '@/features/account-profile/server'
import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server'
import { headers } from '@/next/headers'
import { redirect } from '@/next/navigation'
import {
@@ -56,7 +55,7 @@ const handleProfileError = async (error: unknown) => {
}
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
const queryClient = makeQueryClient()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (accountProfileUrl) {
@@ -65,7 +64,6 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()),
queryClient.prefetchQuery(
serverConsoleQuery.workspaces.current.post.queryOptions({
context,
@@ -6,7 +6,6 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { IS_CE_EDITION } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { LicenseStatus } from '@/features/system-features/constants'
import Link from '@/next/link'
@@ -19,6 +18,9 @@ const NormalForm = () => {
const [isLoading, setIsLoading] = useState(true)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isNonCloudEdition =
systemFeatures.deployment_edition === 'COMMUNITY' ||
systemFeatures.deployment_edition === 'ENTERPRISE'
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
const [showORLine, setShowORLine] = useState(false)
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
@@ -236,7 +238,7 @@ const NormalForm = () => {
{t(($) => $.pp, { ns: 'login' })}
</Link>
</div>
{IS_CE_EDITION && (
{isNonCloudEdition && (
<div className="w-hull mt-2 block system-xs-regular text-text-tertiary">
{t(($) => $.goToInit, { ns: 'login' })}
&nbsp;
+39
View File
@@ -0,0 +1,39 @@
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CommonLayoutError from '@/app/(commonLayout)/error'
import AppError from '@/app/error'
describe('route error recovery', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it.each([
{
name: 'root error',
renderError: (retry: () => void) => <AppError error={new Error('failed')} reset={retry} />,
},
{
name: 'common layout error',
renderError: (retry: () => void) => (
<CommonLayoutError error={new Error('failed')} unstable_retry={retry} />
),
},
])('resets failed queries before retrying the $name', async ({ renderError }) => {
const user = userEvent.setup()
const retry = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => {})
render(
<QueryErrorResetBoundary>
{({ isReset }) => renderError(() => retry(isReset()))}
</QueryErrorResetBoundary>,
)
await user.click(screen.getByRole('button', { name: 'common.errorBoundary.tryAgain' }))
expect(retry).toHaveBeenCalledWith(true)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { QueryClient } from '@tanstack/react-query'
let queryClient: QueryClient
const mocks = vi.hoisted(() => ({
getSystemFeatures: vi.fn(),
getCloudAnalyticsBoundaryState: vi.fn(() => ({ enabled: false })),
requestHeaders: new Headers(),
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => queryClient,
}))
vi.mock('@/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/env')>()
return {
...actual,
getDatasetMap: () => ({}),
}
})
vi.mock('@/features/system-features/server', () => ({
serverSystemFeaturesQueryOptions: () => ({
queryKey: ['console', 'system-features'],
queryFn: mocks.getSystemFeatures,
retry: false,
}),
}))
vi.mock('@/i18n-config/server', () => ({
getLocaleOnServer: async () => 'en-US',
}))
vi.mock('@/next/headers', () => ({
headers: async () => mocks.requestHeaders,
}))
vi.mock('@/app/components/base/analytics-consent/cloud-analytics-state', () => ({
getCloudAnalyticsBoundaryState: mocks.getCloudAnalyticsBoundaryState,
}))
describe('Root layout System Features bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
it('renders with the resolved deployment edition', async () => {
mocks.getSystemFeatures.mockResolvedValue({ deployment_edition: 'CLOUD' })
const { default: RootLayout } = await import('../layout')
await expect(RootLayout({ children: <div>App</div> })).resolves.toBeDefined()
expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(1)
expect(mocks.getCloudAnalyticsBoundaryState).toHaveBeenCalledWith(mocks.requestHeaders, 'CLOUD')
})
it('propagates System Features failures without rendering a fallback', async () => {
const error = new Error('system features unavailable')
mocks.getSystemFeatures.mockRejectedValue(error)
const { default: RootLayout } = await import('../layout')
await expect(RootLayout({ children: <div>App</div> })).rejects.toBe(error)
expect(mocks.getCloudAnalyticsBoundaryState).not.toHaveBeenCalled()
})
})
@@ -12,7 +12,7 @@ import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { validPassword } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@@ -243,7 +243,7 @@ export default function AccountPage() {
wrapperClassName="mt-2"
/>
)}
{!IS_CE_EDITION && (
{systemFeatures.deployment_edition === 'CLOUD' && (
<Button
className="mt-2 text-components-button-destructive-secondary-text"
onClick={() => setShowDeleteAccountModal(true)}
+2 -10
View File
@@ -4,16 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
basePath: '',
isCloudEdition: false,
}))
vi.mock('@/config', () => ({
API_PREFIX: 'http://localhost:5001/console/api',
CSRF_COOKIE_NAME: () => 'csrf_token',
CSRF_HEADER_NAME: 'X-CSRF-Token',
get IS_CLOUD_EDITION() {
return mocks.isCloudEdition
},
}))
vi.mock('server-only', () => ({}))
@@ -52,7 +48,6 @@ describe('auth refresh route', () => {
vi.resetModules()
vi.unstubAllGlobals()
mocks.basePath = ''
mocks.isCloudEdition = false
})
it('should refresh cookies and redirect back to the requested path', async () => {
@@ -244,8 +239,7 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should keep a Cloud staging fallback on the current deployment after refresh', async () => {
mocks.isCloudEdition = true
it('should keep a staging fallback on the current deployment after refresh', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
@@ -260,8 +254,7 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/')
})
it('should carry the current Cloud deployment fallback through signin when refresh fails', async () => {
mocks.isCloudEdition = true
it('should carry the current deployment fallback through signin when refresh fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
@@ -277,7 +270,6 @@ describe('auth refresh route', () => {
})
it('should use the current deployment home when a trusted target loops back to auth refresh', async () => {
mocks.isCloudEdition = true
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
@@ -6,17 +6,10 @@ import { setAnalyticsConsent } from '@/app/components/base/analytics-consent/con
import { useSearchParams } from '@/next/navigation'
import ExternalAttributionRecorder from '../external-attribution-recorder'
const mockConfig = vi.hoisted(() => ({ IS_CLOUD_EDITION: true }))
const { mockRememberCreateAppExternalAttribution } = vi.hoisted(() => ({
mockRememberCreateAppExternalAttribution: vi.fn(),
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
@@ -43,7 +36,6 @@ describe('ExternalAttributionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove('utm_info')
mockConfig.IS_CLOUD_EDITION = true
setAnalyticsConsent('granted')
setSearchParams()
})
@@ -150,14 +142,4 @@ describe('ExternalAttributionRecorder', () => {
})
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
})
it('is a no-op outside the cloud edition', () => {
mockConfig.IS_CLOUD_EDITION = false
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
render(<ExternalAttributionRecorder />)
expect(getUtmInfoCookie()).toBeNull()
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
})
})
@@ -35,7 +35,15 @@ vi.mock('@/service/access-control', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: { get: { queryKey: () => ['system-features'] } },
systemFeatures: {
get: {
queryKey: () => ['system-features'],
queryOptions: (options: Record<string, unknown> = {}) => ({
queryKey: ['system-features'],
...options,
}),
},
},
enterprise: {
webAppAuth: {
updateWebAppWhitelistSubjects: {
@@ -1,7 +1,8 @@
/* oxlint-disable typescript/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import {
AccessModeDisplay,
@@ -3,10 +3,11 @@ import type { IConfigVarProps } from '../index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import { toast } from '@langgenius/dify-ui/toast'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import DebugConfigurationContext from '@/context/debug-configuration'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from '../index'
@@ -1,10 +1,11 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { useStore } from '@/app/components/app/store'
import { InputVarType } from '@/app/components/workflow/types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import ConfigModal from '../index'
@@ -8,7 +8,6 @@ import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { defaultSystemFeatures } from '@/features/system-features/config'
import {
ChunkingMode,
DatasetPermission,
@@ -18,6 +17,7 @@ import {
import { updateDatasetSetting } from '@/service/datasets'
import { useMembers } from '@/service/use-common'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import { RETRIEVE_METHOD } from '@/types/app'
import { DatasetACLPermission } from '@/utils/permission'
import SettingsModal from '../index'
@@ -210,7 +210,7 @@ const renderWithProviders = (dataset: DataSet) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, defaultSystemFeatures)
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, createSystemFeaturesFixture())
return render(
<QueryClientProvider client={queryClient}>
@@ -1,22 +1,18 @@
import type { ReactElement } from 'react'
import type { App } from '@/models/explore'
import type { AppIconType } from '@/types/app'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { trackEvent } from '@/app/components/base/amplitude'
import AppListContext from '@/context/app-list-context'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() }))
const mockConfig = vi.hoisted(() => ({ isCloudEdition: true }))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
}))
const render = (ui: ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
const app: App = {
can_trial: true,
@@ -47,7 +43,6 @@ const app: App = {
describe('AppCard', () => {
beforeEach(() => {
mockConfig.isCloudEdition = true
vi.clearAllMocks()
})
@@ -4,13 +4,14 @@ import { PlusIcon } from '@heroicons/react/20/solid'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiInformation2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon'
import { IS_CLOUD_EDITION } from '@/config'
import AppListContext from '@/context/app-list-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
type AppCardProps = {
@@ -21,8 +22,12 @@ type AppCardProps = {
const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const { app: appBasicInfo } = app
const canViewApp = IS_CLOUD_EDITION
const canViewApp = deploymentEdition === 'CLOUD'
const setShowTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.setShowTryAppPanel)
const handleShowTryAppPanel = useCallback(() => {
trackEvent('preview_template', {
@@ -3,27 +3,26 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import InSiteMessageNotification from '../notification'
const { mockConfig, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
mockConfig: {
isCloudEdition: true,
const { mockEdition, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
mockEdition: {
value: 'CLOUD' as 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' | null,
},
mockNotification: vi.fn(),
mockNotificationDismiss: vi.fn(),
}))
vi.mock(import('@/config'), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
get: {
queryKey: () => ['console', 'systemFeatures', 'get'],
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'systemFeatures', 'get'],
queryFn: () => new Promise(() => {}),
...options,
}),
},
},
notification: {
get: {
queryOptions: (options?: Record<string, unknown>) => ({
@@ -56,6 +55,9 @@ const createWrapper = () => {
},
},
})
queryClient.setQueryData(['console', 'systemFeatures', 'get'], {
deployment_edition: mockEdition.value,
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
@@ -67,7 +69,7 @@ const createWrapper = () => {
describe('InSiteMessageNotification', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.isCloudEdition = true
mockEdition.value = 'CLOUD'
vi.stubGlobal('open', vi.fn())
})
@@ -78,7 +80,7 @@ describe('InSiteMessageNotification', () => {
// Validate query gating and empty state rendering.
describe('Rendering', () => {
it('should render null and skip query when not cloud edition', async () => {
mockConfig.isCloudEdition = false
mockEdition.value = 'COMMUNITY'
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
@@ -1,9 +1,9 @@
'use client'
import type { InSiteMessageActionItem } from './index'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/client'
import InSiteMessage from './index'
@@ -56,20 +56,25 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null {
function InSiteMessageNotification() {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const isCloudEdition = deploymentEdition === 'CLOUD'
const dismissNotificationMutation = useMutation(
consoleQuery.notification.dismiss.post.mutationOptions(),
)
const { data } = useQuery(
consoleQuery.notification.get.queryOptions({
enabled: IS_CLOUD_EDITION,
enabled: isCloudEdition,
}),
)
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
if (!IS_CLOUD_EDITION || !notification || !notification.notification_id) return null
if (!isCloudEdition || !notification || !notification.notification_id) return null
const notificationId = notification.notification_id
const fallbackActions: InSiteMessageActionItem[] = [
@@ -5,17 +5,10 @@ import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { ArchivedLogsNotice } from '../archived-logs-notice'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
@@ -57,6 +50,12 @@ function mockProviderPlan(planType: Plan) {
describe('ArchivedLogsNotice', () => {
const setShowAccountSettingModal = vi.fn()
const renderNotice = () => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
})
return render(<ArchivedLogsNotice />, { wrapper })
}
beforeEach(() => {
vi.clearAllMocks()
@@ -69,7 +68,7 @@ describe('ArchivedLogsNotice', () => {
})
it('should show notice for paid workspace managers', () => {
render(<ArchivedLogsNotice />)
renderNotice()
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
@@ -81,7 +80,7 @@ describe('ArchivedLogsNotice', () => {
it('should not show notice for sandbox workspaces', () => {
mockProviderPlan(Plan.sandbox)
render(<ArchivedLogsNotice />)
renderNotice()
expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument()
})
@@ -1,16 +1,21 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
export function ArchivedLogsNotice() {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { enableBilling, plan } = useProviderContext()
const setShowAccountSettingModal = useModalContextSelector(
@@ -18,7 +23,7 @@ export function ArchivedLogsNotice() {
)
if (
!IS_CLOUD_EDITION ||
deploymentEdition !== 'CLOUD' ||
!isCurrentWorkspaceManager ||
!enableBilling ||
plan.type === Plan.sandbox
@@ -313,6 +313,7 @@ describe('app-card-utils', () => {
expect(snippet).toContain('name: "Alice"')
expect(snippet).toContain('count: "5"')
expect(snippet).toContain('background-color: #FF0000')
expect(snippet).toContain(`baseUrl: 'https://example.com${basePath}'`)
})
it('should generate an embedded script snippet with empty inputs comment', () => {
@@ -5,10 +5,9 @@ import {
interactions,
mockUseModalContext,
scenarios,
setDeploymentEdition,
} from './test-utils'
vi.mock('@/config', () => ({ IS_CE_EDITION: false }))
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
@@ -16,6 +15,7 @@ describe('APIKeyInfoPanel - Cloud Edition', () => {
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('CLOUD')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
@@ -5,11 +5,10 @@ import {
interactions,
mockUseModalContext,
scenarios,
setDeploymentEdition,
textKeys,
} from './test-utils'
vi.mock('@/config', () => ({ IS_CE_EDITION: true }))
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
@@ -17,6 +16,7 @@ describe('APIKeyInfoPanel - Community Edition', () => {
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('COMMUNITY')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
@@ -1,7 +1,8 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { RenderOptions } from '@testing-library/react'
import type { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import {
@@ -9,6 +10,7 @@ import {
useModalContextSelector as actualUseModalContextSelector,
} from '@/context/modal-context'
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import APIKeyInfoPanel from '../index'
const { mockRouterPush } = vi.hoisted(() => ({
@@ -102,6 +104,7 @@ type APIKeyInfoPanelRenderOptions = {
} & Omit<RenderOptions, 'wrapper'>
const mainButtonName = /appOverview\.apiKeyInfo\.setAPIBtn/
let deploymentEdition: DeploymentEdition = 'COMMUNITY'
// Setup function to configure mocks
function setupMocks(overrides: MockOverrides = {}) {
@@ -129,7 +132,10 @@ function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
setupMocks(mockOverrides)
return render(<APIKeyInfoPanel />, renderOptions)
return renderWithConsoleQuery(<APIKeyInfoPanel />, {
...renderOptions,
systemFeatures: { deployment_edition: deploymentEdition },
})
}
// Helper functions for common test scenarios
@@ -200,5 +206,9 @@ export function clearAllMocks() {
vi.clearAllMocks()
}
export function setDeploymentEdition(value: DeploymentEdition) {
deploymentEdition = value
}
// Export mock functions for external access
export { defaultModalContext, mockUseModalContext }
@@ -3,17 +3,22 @@ import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import { IS_CE_EDITION } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
const APIKeyInfoPanel: FC = () => {
const isCloud = !IS_CE_EDITION
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const isCloud = deploymentEdition === 'CLOUD'
const { isAPIKeySet } = useProviderContext()
const openIntegrationsSetting = useIntegrationsSetting()
@@ -2,7 +2,6 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { IS_CE_EDITION } from '@/config'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
@@ -177,20 +176,16 @@ export const getEmbeddedScriptSnippet = ({
primaryColor: string
isTestEnv?: boolean
inputValues: Record<string, WorkflowLaunchInputValue>
}) =>
`<script>
}) => {
return `<script>
window.difyChatbotConfig = {
token: '${token}'${
isTestEnv
? `,
isDev: true`
: ''
}${
IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''
}${
},
baseUrl: '${url}${basePath}'${
webAppRoute !== 'chatbot'
? `,
routeSegment: '${webAppRoute}'`
@@ -221,6 +216,7 @@ export const getEmbeddedScriptSnippet = ({
height: 40rem !important;
}
</style>`
}
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
@@ -105,6 +105,10 @@ vi.mock('@/service/client', () => ({
systemFeatures: {
get: {
queryKey: () => ['console', 'systemFeatures', 'get'],
queryOptions: (options: Record<string, unknown> = {}) => ({
queryKey: ['console', 'systemFeatures', 'get'],
...options,
}),
},
},
},
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
const mockConsent = vi.hoisted(() => ({
value: 'granted' as 'unknown' | 'denied' | 'granted',
@@ -17,12 +16,6 @@ vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
},
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
get isAmplitudeEnabled() {
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
},
}))
vi.mock('@amplitude/analytics-browser', () => ({
@@ -44,7 +37,6 @@ describe('AmplitudeProvider', () => {
vi.resetModules()
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
mockConsent.value = 'granted'
;({ AmplitudeProvider } = await import('../AmplitudeProvider'))
})
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
let ensureAmplitudeInitialized: typeof import('../init').ensureAmplitudeInitialized
@@ -13,12 +12,6 @@ vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
},
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
get isAmplitudeEnabled() {
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
},
}))
vi.mock('@amplitude/analytics-browser', () => ({
@@ -36,7 +29,6 @@ describe('amplitude init helper', () => {
vi.resetModules()
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
;({ ensureAmplitudeInitialized } = await import('../init'))
})
@@ -2,7 +2,6 @@ import { flushEvents, resetUser, setUserId, setUserProperties, trackEvent } from
const mockState = vi.hoisted(() => ({
consent: 'granted' as 'unknown' | 'denied' | 'granted',
enabled: true,
initialized: true,
}))
@@ -24,12 +23,6 @@ const MockIdentify = vi.hoisted(
},
)
vi.mock('@/config', () => ({
get isAmplitudeEnabled() {
return mockState.enabled
},
}))
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
getAnalyticsConsent: () => mockState.consent,
}))
@@ -51,12 +44,11 @@ describe('amplitude utils', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.consent = 'granted'
mockState.enabled = true
mockState.initialized = true
})
describe('trackEvent', () => {
it('should call amplitude.track and return its result when amplitude is enabled', () => {
it('should call amplitude.track and return its result when the consented SDK is initialized', () => {
const trackResult = { promise: Promise.resolve({}) }
mockTrack.mockReturnValue(trackResult)
@@ -67,8 +59,8 @@ describe('amplitude utils', () => {
expect(result).toBe(trackResult)
})
it('should not call amplitude.track when amplitude is disabled', () => {
mockState.enabled = false
it('should not call amplitude.track before the SDK initializes', () => {
mockState.initialized = false
trackEvent('dataset_created', { source: 'wizard' })
@@ -93,7 +85,7 @@ describe('amplitude utils', () => {
})
describe('flushEvents', () => {
it('should call amplitude.flush and return its result when amplitude is enabled', () => {
it('should call amplitude.flush and return its result when the consented SDK is initialized', () => {
const flushResult = { promise: Promise.resolve() }
mockFlush.mockReturnValue(flushResult)
@@ -103,8 +95,8 @@ describe('amplitude utils', () => {
expect(result).toBe(flushResult)
})
it('should not call amplitude.flush when amplitude is disabled', () => {
mockState.enabled = false
it('should not call amplitude.flush before the SDK initializes', () => {
mockState.initialized = false
flushEvents()
@@ -121,15 +113,15 @@ describe('amplitude utils', () => {
})
describe('setUserId', () => {
it('should call amplitude.setUserId when amplitude is enabled', () => {
it('should call amplitude.setUserId when the consented SDK is initialized', () => {
setUserId('user-123')
expect(mockSetUserId).toHaveBeenCalledTimes(1)
expect(mockSetUserId).toHaveBeenCalledWith('user-123')
})
it('should not call amplitude.setUserId when amplitude is disabled', () => {
mockState.enabled = false
it('should not call amplitude.setUserId before the SDK initializes', () => {
mockState.initialized = false
setUserId('user-123')
@@ -146,7 +138,7 @@ describe('amplitude utils', () => {
})
describe('setUserProperties', () => {
it('should build identify event and call amplitude.identify when amplitude is enabled', () => {
it('should build an identify event when the consented SDK is initialized', () => {
const properties = {
role: 'owner',
seats: 3,
@@ -165,8 +157,8 @@ describe('amplitude utils', () => {
])
})
it('should not call amplitude.identify when amplitude is disabled', () => {
mockState.enabled = false
it('should not call amplitude.identify before the SDK initializes', () => {
mockState.initialized = false
setUserProperties({ role: 'owner' })
@@ -183,14 +175,14 @@ describe('amplitude utils', () => {
})
describe('resetUser', () => {
it('should call amplitude.reset when amplitude is enabled', () => {
it('should call amplitude.reset when the consented SDK is initialized', () => {
resetUser()
expect(mockReset).toHaveBeenCalledTimes(1)
})
it('should not call amplitude.reset when amplitude is disabled', () => {
mockState.enabled = false
it('should not call amplitude.reset before the SDK initializes', () => {
mockState.initialized = false
resetUser()
+3 -3
View File
@@ -1,6 +1,6 @@
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
import { AMPLITUDE_API_KEY } from '@/config'
export type AmplitudeInitializationOptions = {
sessionReplaySampleRate?: number
@@ -61,7 +61,7 @@ const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
export const ensureAmplitudeInitialized = ({
sessionReplaySampleRate = 0.5,
}: AmplitudeInitializationOptions = {}) => {
if (!isAmplitudeEnabled || isAmplitudeInitialized) return
if (!AMPLITUDE_API_KEY || isAmplitudeInitialized) return
isAmplitudeInitialized = true
@@ -90,6 +90,6 @@ export const ensureAmplitudeInitialized = ({
}
export const setAmplitudeOptOut = (optOut: boolean) => {
if (!isAmplitudeEnabled || !isAmplitudeInitialized) return
if (!AMPLITUDE_API_KEY || !isAmplitudeInitialized) return
amplitude.setOptOut(optOut)
}
+1 -3
View File
@@ -1,10 +1,8 @@
import * as amplitude from '@amplitude/analytics-browser'
import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { isAmplitudeEnabled } from '@/config'
import { getIsAmplitudeInitialized } from './init'
const canUseAmplitude = () =>
isAmplitudeEnabled && getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
/**
* Track custom event
@@ -3,7 +3,6 @@ import { render } from '@testing-library/react'
type ConfigState = {
cookieYesSiteKey: string
isCloudEdition: boolean
isProd: boolean
webPrefix: string | undefined
}
@@ -11,7 +10,6 @@ type ConfigState = {
const { configState, mockHeadersGet } = vi.hoisted(() => ({
configState: {
cookieYesSiteKey: 'site-key',
isCloudEdition: true,
isProd: true,
webPrefix: 'https://cloud.dify.ai',
} as ConfigState,
@@ -22,9 +20,6 @@ vi.mock('@/config', () => ({
get COOKIEYES_SITE_KEY() {
return configState.cookieYesSiteKey
},
get IS_CLOUD_EDITION() {
return configState.isCloudEdition
},
get IS_PROD() {
return configState.isProd
},
@@ -62,7 +57,7 @@ async function renderBoundary() {
import('../cloud-analytics-boundary'),
import('../cloud-analytics-state'),
])
const state = getCloudAnalyticsBoundaryState({ get: mockHeadersGet })
const state = getCloudAnalyticsBoundaryState({ get: mockHeadersGet }, 'CLOUD')
const view = render(<CloudAnalyticsBoundary {...state} />)
return { ...view, state }
}
@@ -72,7 +67,6 @@ describe('CloudAnalyticsBoundary', () => {
vi.clearAllMocks()
vi.resetModules()
configState.cookieYesSiteKey = 'site-key'
configState.isCloudEdition = true
configState.isProd = true
configState.webPrefix = 'https://cloud.dify.ai'
mockHeadersGet.mockImplementation((name: string) => {
@@ -143,4 +137,17 @@ describe('CloudAnalyticsBoundary', () => {
expect(state.enabled).toBe(false)
expect(container.querySelector('script')).toBeNull()
})
it.each(['COMMUNITY', 'ENTERPRISE', null] as const)(
'disables analytics when deployment edition is %s',
async (deploymentEdition) => {
const { CloudAnalyticsBoundary } = await import('../cloud-analytics-boundary')
const { getCloudAnalyticsBoundaryState } = await import('../cloud-analytics-state')
const state = getCloudAnalyticsBoundaryState({ get: mockHeadersGet }, deploymentEdition)
const { container } = render(<CloudAnalyticsBoundary {...state} />)
expect(state.enabled).toBe(false)
expect(container.querySelector('script')).toBeNull()
},
)
})
@@ -17,6 +17,10 @@ vi.mock('@/app/components/base/amplitude', () => ({
),
}))
vi.mock('@/app/components/external-attribution-recorder', () => ({
default: () => <span data-testid="external-attribution-recorder" />,
}))
describe('CloudAnalyticsRuntime', () => {
beforeEach(() => {
mockState.pathname = '/signin'
@@ -26,6 +30,7 @@ describe('CloudAnalyticsRuntime', () => {
const { rerender } = render(<CloudAnalyticsRuntime />)
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument()
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
mockState.pathname = '/integrations'
@@ -1,6 +1,7 @@
'use client'
import AmplitudeProvider from '@/app/components/base/amplitude'
import ExternalAttributionRecorder from '@/app/components/external-attribution-recorder'
import { usePathname } from '@/next/navigation'
import { CookieYesConsentBridge } from './cookieyes-consent-bridge'
import { isCloudAnalyticsPath } from './request-boundary'
@@ -12,6 +13,7 @@ export function CloudAnalyticsRuntime() {
<>
<CookieYesConsentBridge />
<AmplitudeProvider active={isCloudAnalyticsPath(pathname)} />
<ExternalAttributionRecorder />
</>
)
}
@@ -1,4 +1,5 @@
import { COOKIEYES_SITE_KEY, IS_CLOUD_EDITION, IS_PROD, WEB_PREFIX } from '@/config'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config'
import { isCloudAnalyticsRequest } from './request-boundary'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
@@ -15,12 +16,13 @@ export type CloudAnalyticsBoundaryState = {
export function getCloudAnalyticsBoundaryState(
requestHeaders: RequestHeaders,
deploymentEdition: DeploymentEdition | null,
): CloudAnalyticsBoundaryState {
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || '/'
const requestHost = requestHeaders.get('x-forwarded-host') || requestHeaders.get('host')
const enabled = isCloudAnalyticsRequest({
cookieYesSiteKey: COOKIEYES_SITE_KEY,
isCloudEdition: IS_CLOUD_EDITION,
isCloudEdition: deploymentEdition === 'CLOUD',
isProd: IS_PROD,
pathname,
requestHost,
@@ -12,14 +12,8 @@ type AudioPlayerCtorArgs = [
type MockAudioPlayerInstance = {
setCallback: ReturnType<typeof vi.fn>
pauseAudio: ReturnType<typeof vi.fn>
destroy: ReturnType<typeof vi.fn>
resetMsgId: ReturnType<typeof vi.fn>
cacheBuffers: Array<ArrayBuffer>
sourceBuffer:
| {
abort: ReturnType<typeof vi.fn>
}
| undefined
}
const mockState = vi.hoisted(() => ({
@@ -31,10 +25,8 @@ const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
const MockAudioPlayer = vi.hoisted(() => {
return class MockAudioPlayerClass {
setCallback = vi.fn()
pauseAudio = vi.fn()
destroy = vi.fn()
resetMsgId = vi.fn()
cacheBuffers = [new ArrayBuffer(1)]
sourceBuffer = { abort: vi.fn() }
constructor(...args: AudioPlayerCtorArgs) {
mockAudioPlayerConstructor(...args)
@@ -132,9 +124,7 @@ describe('AudioPlayerManager', () => {
callback,
)
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
expect(previous!.cacheBuffers).toEqual([])
expect(previous!.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
expect(previous!.destroy).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
expect(next).toBe(mockState.instances[1])
})
@@ -144,7 +134,7 @@ describe('AudioPlayerManager', () => {
const callback = vi.fn()
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
const previous = mockState.instances[0]
previous!.pauseAudio.mockImplementation(() => {
previous!.destroy.mockImplementation(() => {
throw new Error('cleanup failure')
})
@@ -152,7 +142,7 @@ describe('AudioPlayerManager', () => {
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
}).not.toThrow()
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
expect(previous!.destroy).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
})
})
@@ -3,15 +3,8 @@ import { waitFor } from '@testing-library/react'
import { AppSourceType } from '@/service/share'
import AudioPlayer from '../audio'
const mockToastNotify = vi.hoisted(() => vi.fn())
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (message: string) => mockToastNotify({ type: 'error', message }),
},
}))
vi.mock('@/service/share', () => ({
AppSourceType: {
webApp: 'webApp',
@@ -22,7 +15,7 @@ vi.mock('@/service/share', () => ({
type AudioEventName =
| 'ended'
| 'paused'
| 'pause'
| 'loaded'
| 'play'
| 'timeupdate'
@@ -30,6 +23,7 @@ type AudioEventName =
| 'canplay'
| 'error'
| 'sourceopen'
| 'updateend'
type AudioEventListener = () => void
@@ -51,12 +45,31 @@ type AudioResponse = {
class MockSourceBuffer {
updating = false
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
addEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
const listeners = this.listeners[event] || []
listeners.push(listener)
this.listeners[event] = listeners
})
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
})
appendBuffer = vi.fn((_buffer: ArrayBuffer) => undefined)
abort = vi.fn(() => undefined)
emit(event: AudioEventName) {
const listeners = this.listeners[event] || []
listeners.forEach((listener) => {
listener()
})
}
}
class MockMediaSource {
readyState: 'open' | 'closed' = 'open'
readyState: 'open' | 'closed' | 'ended' = 'closed'
sourceBuffer = new MockSourceBuffer()
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
@@ -66,10 +79,15 @@ class MockMediaSource {
this.listeners[event] = listeners
})
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
})
addSourceBuffer = vi.fn((_contentType: string) => this.sourceBuffer)
endOfStream = vi.fn(() => undefined)
emit(event: AudioEventName) {
if (event === 'sourceopen') this.readyState = 'open'
const listeners = this.listeners[event] || []
listeners.forEach((listener) => {
listener()
@@ -110,7 +128,7 @@ class MockAudio {
}
class MockAudioContext {
state: 'running' | 'suspended' = 'running'
state: 'interrupted' | 'running' | 'suspended' = 'running'
destination = {}
connect = vi.fn(() => undefined)
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
@@ -121,9 +139,11 @@ class MockAudioContext {
this.state = 'running'
})
suspend = vi.fn(() => {
suspend = vi.fn(async () => {
this.state = 'suspended'
})
close = vi.fn(async () => undefined)
}
const testState = {
@@ -133,6 +153,8 @@ const testState = {
}
class MockMediaSourceCtor extends MockMediaSource {
static isTypeSupported = vi.fn(() => true)
constructor() {
super()
testState.mediaSources.push(this)
@@ -156,6 +178,7 @@ class MockAudioContextCtor extends MockAudioContext {
const originalAudio = globalThis.Audio
const originalAudioContext = globalThis.AudioContext
const originalCreateObjectURL = globalThis.URL.createObjectURL
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
const originalMediaSource = window.MediaSource
const originalManagedMediaSource = window.ManagedMediaSource
@@ -192,6 +215,7 @@ describe('AudioPlayer', () => {
testState.mediaSources = []
testState.audios = []
testState.audioContexts = []
MockMediaSourceCtor.isTypeSupported.mockReturnValue(true)
Object.defineProperty(globalThis, 'Audio', {
configurable: true,
@@ -208,6 +232,11 @@ describe('AudioPlayer', () => {
writable: true,
value: vi.fn(() => 'blob:mock-url'),
})
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: vi.fn(),
})
setMediaSourceSupport({ mediaSource: true, managedMediaSource: false })
})
@@ -228,6 +257,11 @@ describe('AudioPlayer', () => {
writable: true,
value: originalCreateObjectURL,
})
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: originalRevokeObjectURL,
})
Object.defineProperty(window, 'MediaSource', {
configurable: true,
writable: true,
@@ -256,7 +290,7 @@ describe('AudioPlayer', () => {
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
})
it('should notify unsupported browser when no MediaSource implementation exists', () => {
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
@@ -264,12 +298,22 @@ describe('AudioPlayer', () => {
expect(player.mediaSource).toBeNull()
expect(audio!.src).toBe('')
expect(mockToastNotify).toHaveBeenCalledTimes(1)
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
expect(audio!.autoplay).toBe(false)
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
})
it('should use complete-audio fallback when MP3 MediaSource is unsupported', () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const audio = testState.audios[0]
expect(MockMediaSourceCtor.isTypeSupported).toHaveBeenCalledWith('audio/mpeg')
expect(player.mediaSource).toBeNull()
expect(testState.mediaSources).toHaveLength(0)
expect(audio!.src).toBe('')
expect(audio!.autoplay).toBe(false)
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
})
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
@@ -283,6 +327,17 @@ describe('AudioPlayer', () => {
expect(audio!.disableRemotePlayback).toBe(true)
expect(audio!.controls).toBe(true)
})
it('should configure ManagedMediaSource when both media source implementations exist', () => {
setMediaSourceSupport({ mediaSource: true, managedMediaSource: true })
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
const audio = testState.audios[0]
expect(player.mediaSource).not.toBeNull()
expect(audio!.disableRemotePlayback).toBe(true)
expect(audio!.controls).toBe(true)
})
})
describe('event wiring', () => {
@@ -294,7 +349,7 @@ describe('AudioPlayer', () => {
audio!.emit('play')
audio!.emit('ended')
audio!.emit('error')
audio!.emit('paused')
audio!.emit('pause')
audio!.emit('loaded')
audio!.emit('timeupdate')
audio!.emit('loadeddate')
@@ -354,6 +409,7 @@ describe('AudioPlayer', () => {
})
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const callback = vi.fn()
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(500, [{ value: new Uint8Array([1]), done: true }]),
@@ -366,25 +422,171 @@ describe('AudioPlayer', () => {
expect(callback).toHaveBeenCalledWith('error')
})
expect(player.isLoadData).toBe(false)
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
expect(testState.audios[0]!.play).not.toHaveBeenCalled()
})
it('should resume and play immediately when playAudio is called in suspended loaded state', async () => {
it('should play a complete MP3 blob when MediaSource does not support audio/mpeg', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const callback = vi.fn()
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(200, [
{ value: new Uint8Array([1, 2]), done: false },
{ value: new Uint8Array([3, 4]), done: true },
]),
)
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
player.playAudio()
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
expect(player.mediaSource).toBeNull()
expect(player.cacheBuffers).toHaveLength(0)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
expect(audioBlob).toBeInstanceOf(Blob)
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
expect(audio!.src).toBe('blob:mock-url')
expect(callback).toHaveBeenCalledWith('play')
})
it('should wait for the complete MP3 before retrying playback without MediaSource', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
let resolveResponse: ((response: AudioResponse) => void) | undefined
mockTextToAudioStream.mockImplementationOnce(
() =>
new Promise<AudioResponse>((resolve) => {
resolveResponse = resolve
}),
)
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
const audio = testState.audios[0]
player.playAudio()
player.playAudio()
expect(audio!.play).not.toHaveBeenCalled()
resolveResponse?.(makeAudioResponse(200, [{ value: new Uint8Array([1, 2]), done: true }]))
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
})
it.each(['suspended', 'interrupted'] as const)(
'should resume and play immediately when playAudio is called in %s loaded state',
async (audioContextState) => {
const callback = vi.fn()
const player = new AudioPlayer(
'/text-to-audio',
false,
'msg-1',
'hello',
undefined,
callback,
)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.isLoadData = true
audioContext!.state = audioContextState
player.playAudio()
await waitFor(() => {
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
},
)
it('should request media playback before a suspended audio context finishes resuming', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
let resolveResume: (() => void) | undefined
player.isLoadData = true
audioContext!.state = 'suspended'
audioContext!.resume.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveResume = () => {
audioContext!.state = 'running'
resolve()
}
}),
)
player.playAudio()
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
resolveResume?.()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
})
it.each(['suspended', 'interrupted'] as const)(
'should resume a %s audio context when the media element is still playing',
async (audioContextState) => {
const callback = vi.fn()
const player = new AudioPlayer(
'/text-to-audio',
false,
'msg-1',
'hello',
undefined,
callback,
)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.isLoadData = true
audio!.paused = false
audioContext!.state = audioContextState
player.playAudio()
await waitFor(() => {
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
expect(audio!.play).not.toHaveBeenCalled()
},
)
it('should report an error when the audio context remains interrupted and allow retry', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
player.isLoadData = true
audio!.paused = false
audioContext!.state = 'suspended'
audioContext!.resume.mockImplementationOnce(async () => {
audioContext!.state = 'interrupted'
})
player.playAudio()
await Promise.resolve()
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
expect(callback).not.toHaveBeenCalledWith('play')
audioContext!.resume.mockImplementationOnce(async () => {
audioContext!.state = 'running'
})
player.playAudio()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
expect(audio!.play).not.toHaveBeenCalled()
})
it('should play ended audio when data is already loaded', () => {
it('should play ended audio when data is already loaded', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
@@ -395,11 +597,13 @@ describe('AudioPlayer', () => {
audio!.ended = true
player.playAudio()
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
await waitFor(() => {
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
})
it('should only emit play callback without replaying when loaded audio is already playing', () => {
it('should report loaded audio that is already playing without replaying it', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
@@ -407,6 +611,7 @@ describe('AudioPlayer', () => {
player.isLoadData = true
audioContext!.state = 'running'
audio!.paused = false
audio!.ended = false
player.playAudio()
@@ -451,22 +656,20 @@ describe('AudioPlayer', () => {
})
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
vi.useFakeTimers()
try {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const mediaSource = testState.mediaSources[0]
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const mediaSource = testState.mediaSources[0]
await player.playAudioWithAudio('', true)
await vi.advanceTimersByTimeAsync(40)
await player.playAudioWithAudio('', true)
expect(player.isLoadData).toBe(false)
expect(player.cacheBuffers).toHaveLength(0)
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
} finally {
vi.useRealTimers()
}
expect(player.isLoadData).toBe(false)
expect(player.cacheBuffers).toHaveLength(0)
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.emit('sourceopen')
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
@@ -479,8 +682,8 @@ describe('AudioPlayer', () => {
mediaSource!.emit('sourceopen')
audio!.paused = true
audioContext!.state = 'suspended'
await player.playAudioWithAudio(audioBase64, true)
await Promise.resolve()
expect(player.isLoadData).toBe(true)
expect(player.cacheBuffers).toHaveLength(0)
@@ -488,9 +691,11 @@ describe('AudioPlayer', () => {
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
await waitFor(() => {
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
})
it('should skip playback when playAudioWithAudio is called with play=false', async () => {
@@ -507,6 +712,88 @@ describe('AudioPlayer', () => {
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should combine automatic TTS chunks into a playable MP3 blob without MediaSource', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
await player.playAudioWithAudio(Buffer.from([3, 4]).toString('base64'), true)
expect(audio!.play).not.toHaveBeenCalled()
expect(player.cacheBuffers).toHaveLength(2)
await player.playAudioWithAudio('', false)
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
expect(player.cacheBuffers).toHaveLength(0)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
expect(audio!.src).toBe('blob:mock-url')
expect(callback).toHaveBeenCalledWith('play')
})
it('should not start fallback playback after it is paused while buffering', async () => {
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
const audio = testState.audios[0]
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
player.pauseAudio()
await player.playAudioWithAudio('', false)
expect(audio!.autoplay).toBe(false)
expect(audio!.play).not.toHaveBeenCalled()
expect(audio!.src).toBe('blob:mock-url')
})
it('should fall back to a complete MP3 when addSourceBuffer throws', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const mediaSource = testState.mediaSources[0]
const audio = testState.audios[0]
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
throw new DOMException('Unsupported type', 'NotSupportedError')
})
mediaSource!.emit('sourceopen')
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
await player.playAudioWithAudio('', false)
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
expect(player.mediaSource).toBeNull()
expect(audio!.autoplay).toBe(false)
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
})
it('should complete buffered fallback when addSourceBuffer throws after stream end', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
const mediaSource = testState.mediaSources[0]
const audio = testState.audios[0]
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
throw new DOMException('Unsupported type', 'NotSupportedError')
})
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
await player.playAudioWithAudio('', false)
audio!.paused = true
mediaSource!.emit('sourceopen')
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(2))
expect(player.mediaSource).toBeNull()
expect(player.cacheBuffers).toHaveLength(0)
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 2 })
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2]))
})
it('should play immediately for ended audio in playAudioWithAudio', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
@@ -517,7 +804,7 @@ describe('AudioPlayer', () => {
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
})
it('should not replay when played list exists in playAudioWithAudio', async () => {
@@ -534,18 +821,63 @@ describe('AudioPlayer', () => {
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should replay when paused is false and played list is empty in playAudioWithAudio', async () => {
it('should report a play failure and retry without requesting audio again', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const mediaSource = testState.mediaSources[0]
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(200, [{ value: undefined, done: true }]),
)
audio!.play.mockRejectedValueOnce(new DOMException('Playback aborted', 'AbortError'))
audio!.paused = false
audio!.ended = false
audio!.played = null
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
mediaSource!.emit('sourceopen')
player.playAudio()
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
expect(callback).not.toHaveBeenCalledWith('play')
expect(player.isLoadData).toBe(true)
audio!.play.mockImplementationOnce(async () => {
audio!.paused = false
})
player.playAudio()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
expect(audio!.play).toHaveBeenCalledTimes(2)
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
})
it('should report a resume failure and allow playback to be retried', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
const mediaSource = testState.mediaSources[0]
mockTextToAudioStream.mockResolvedValue(
makeAudioResponse(200, [{ value: undefined, done: true }]),
)
audioContext!.state = 'suspended'
audioContext!.resume.mockRejectedValueOnce(new DOMException('Not allowed', 'NotAllowedError'))
mediaSource!.emit('sourceopen')
player.playAudio()
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
expect(player.isLoadData).toBe(true)
audioContext!.resume.mockImplementationOnce(async () => {
audioContext!.state = 'running'
})
player.playAudio()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
})
})
@@ -562,7 +894,7 @@ describe('AudioPlayer', () => {
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
it('should finish stream when receiveAudioData gets empty bytes', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const finishStream = vi
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
@@ -586,6 +918,52 @@ describe('AudioPlayer', () => {
expect(player.cacheBuffers.length).toBe(1)
})
it('should preserve audio received before sourceopen and append it once ready', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array([1, 2, 3]),
)
expect(player.cacheBuffers).toHaveLength(1)
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
mediaSource!.emit('sourceopen')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(player.cacheBuffers).toHaveLength(0)
})
it('should append queued buffers in order after updateend', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.sourceBuffer.updating = true
const first = new Uint8Array([1])
const second = new Uint8Array([2])
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
first,
)
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
second,
)
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0])).toEqual(
first,
)
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2)
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[1]![0])).toEqual(
second,
)
})
it('should append previously queued buffer before new one when source buffer is idle', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
@@ -603,19 +981,68 @@ describe('AudioPlayer', () => {
expect(player.cacheBuffers.length).toBe(1)
})
it('should append cache chunks and end stream when finishStream drains buffers', () => {
vi.useFakeTimers()
it('should end the stream only after the final queued buffer is appended', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.updating = true
player.cacheBuffers = [new ArrayBuffer(3)]
;(player as unknown as { finishStream: () => void }).finishStream()
vi.advanceTimersByTime(50)
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('should end an open stream at most once', () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
;(player as unknown as { finishStream: () => void }).finishStream()
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
})
it.each(['closed', 'ended'] as const)('should not end a %s media source', (readyState) => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
mediaSource!.emit('sourceopen')
mediaSource!.readyState = readyState
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
})
it('should stop buffering and release browser resources after destroy', async () => {
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
const mediaSource = testState.mediaSources[0]
const audio = testState.audios[0]
const audioContext = testState.audioContexts[0]
mediaSource!.emit('sourceopen')
player.destroy()
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
new Uint8Array([1]),
)
;(player as unknown as { finishStream: () => void }).finishStream()
mediaSource!.sourceBuffer.emit('updateend')
await Promise.resolve()
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
expect(audio!.pause).toHaveBeenCalledTimes(1)
expect(audioContext!.close).toHaveBeenCalledTimes(1)
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
})
})

Some files were not shown because too many files have changed in this diff Show More