Compare commits

..
Author SHA1 Message Date
GareArc f729bc87a2 feat(inner_api): add endpoint to invalidate plugin model providers cache
Enterprise installs plugins into a workspace by talking to the plugin
daemon directly, so Dify never learns that a tenant's cached model
provider list is stale and serves it for the full 24h TTL.

Expose the existing PluginService.invalidate_plugin_model_providers_cache
over the inner API so enterprise can drop the cache for the tenants it
just changed. Batching is left to the caller.
2026-07-23 20:51:52 -07:00
GareArc 160a1bc405 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-22 22:51:51 -07:00
GareArc 6b8937f3e1 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into gareth/esq1-170-rbac-for-agent 2026-07-22 22:50:36 -07:00
GareArc d673cc96ae fix(agent): drop agent.manage gate from workflow copy-from-roster 2026-07-22 22:50:14 -07:00
GareArc 553c38b1f2 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-22 22:17:03 -07:00
Xiyuan ChenandGitHub 9e74520b50 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-22 22:01:46 -07:00
GareArc c56b46a318 fix(agent): gate creators platform publish for agent apps 2026-07-22 21:58:28 -07:00
GareArc d16d2d1efa Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into gareth/esq1-170-rbac-for-agent 2026-07-22 21:32:30 -07:00
GareArc 91126a5fda fix(agent): stack agent.manage onto agent app management and import paths
Roster composer writes keep their app_edit / snippets gate and require
workspace agent.manage in addition, instead of replacing it. Generic app
management routes (update/delete/copy/export/name/icon/site/site-enable/
api-enable/api-keys) enforce agent.manage when the target is a roster
Agent App and reject hidden workflow-only backing apps outright, which
stay usable only through the Agent runtime surfaces. Importing an Agent
DSL requires agent.manage and the denial surfaces as 403 instead of a
generic failed import.
2026-07-22 21:31:56 -07:00
GareArc 8482167f1c fix(agent): authorize workflow-only agents against their parent workflow app
The RBAC resource resolver returned the hidden runtime backing app for
workflow-only agents, which never receives a resource access policy, so
per-resource overrides on the parent workflow app were ignored. Resolve
agent_id to the policy-owning agent.app_id instead and keep
backing_app_id strictly runtime-only.
2026-07-22 21:31:43 -07:00
yyhandGitHub 6cd10f5e04 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-23 12:07:51 +08:00
GareArc 1b5fbb2142 Merge remote-tracking branch 'origin/main' into gareth/esq1-170-rbac-for-agent 2026-07-22 20:24:53 -07:00
yunlu.wen 60885db938 Merge branch 'feat/redis-azure-managed-identity' into deploy/enterprise 2026-07-23 11:13:31 +08:00
autofix-ci[bot]andGitHub 1ba65a3431 [autofix.ci] apply automated fixes 2026-07-23 02:53:05 +00:00
GareArc 09d7e44bd1 fix(agent): gate roster composer writes with agent.manage
Require agent.manage on the composer endpoints that create or write shared
roster/normal drafts (copy-from-roster, save-to-roster, and the agent
composer PUT) for both app and snippet variants, leaving the local node and
snippet draft saves on their existing edit permissions. Hide the Make Copy
and Save to Roster controls when the user cannot manage agents.
2026-07-22 19:49:05 -07:00
GareArc 67451b0ecc Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-22 19:08:36 -07:00
yunlu.wen cd62373421 valid pubsub url when mi is enabled 2026-07-23 09:50:08 +08:00
yyh 245c6c3525 fix(web): merge agent console link styles 2026-07-23 09:37:59 +08:00
yyh 4e95e73d86 fix(web): align enterprise edition configuration 2026-07-23 09:37:51 +08:00
GareArc fd88da9743 refactor(web): gate community-edition tip on deployment edition
Replace the enterprise_enabled system feature with an env-derived edition
signal. enterprise_enabled expanded both the Console and Web API contracts,
and via cloudSystemFeatures it made Cloud render the Community Edition
warning.

- Drop enterprise_enabled from SystemFeatureModel; regenerate contracts and
  openapi docs.
- Add NEXT_PUBLIC_ENTERPRISE_ENABLED (forwarding the existing backend
  ENTERPRISE_ENABLED) and derive IS_ENTERPRISE_EDITION / IS_COMMUNITY_EDITION.
- CommunityEditionTip renders only on community edition, so enterprise and
  cloud both correctly hide it.

Review fixes:
- edit-in-console-link: render the enabled control as an anchor instead of a
  Button-rendered link, which forced type="button" onto the <a>; export
  buttonVariants for the styling.
- agents-access-guard: named export; redirect to '/'.
- Remove the redundant @detailSidebar/agents access-guard layout.
2026-07-22 17:44:27 -07:00
yunlu.wen c5b345636d support custom pub/sub url 2026-07-22 17:01:55 +08:00
yunlu.wen 2e30c59652 fix: ssl cert configs default to None 2026-07-22 16:07:50 +08:00
autofix-ci[bot]andGitHub 6cde1271aa [autofix.ci] apply automated fixes 2026-07-22 07:39:00 +00:00
yunlu.wen 1ff2d022ac fix typing 2026-07-22 15:35:33 +08:00
autofix-ci[bot]andGitHub 0064a2d8ff [autofix.ci] apply automated fixes 2026-07-22 07:23:27 +00:00
yunlu.wen 8dd62b6cef delegate get_credentials directly 2026-07-22 15:19:52 +08:00
yunlu.wen 09338631bc move validation to root 2026-07-22 15:15:36 +08:00
yunlu.wen 1c58bd0f15 move db validation to config layer 2026-07-22 14:37:03 +08:00
yunlu.wen 8ce3063ece add to env example 2026-07-22 13:31:52 +08:00
yunlu.wen 28a7c247b9 feat: support entraid for azure managed redis 2026-07-22 13:27:08 +08:00
GareArc bddf574994 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-21 16:51:42 -07:00
GareArc bfd4de159e Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into gareth/esq1-170-rbac-for-agent 2026-07-21 16:50:02 -07:00
GareArc 2279298c1a Merge remote-tracking branch 'origin/main' into gareth/esq1-170-rbac-for-agent 2026-07-21 16:38:49 -07:00
autofix-ci[bot]andGitHub 6056646eea [autofix.ci] apply automated fixes 2026-07-21 10:06:36 +00:00
Xiyuan ChenandGitHub e2d0528e29 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-21 03:02:56 -07:00
GareArc ac87341d69 Merge remote-tracking branch 'origin/main' into gareth/esq1-170-rbac-for-agent
# Conflicts:
#	api/services/enterprise/rbac_service.py
2026-07-21 03:02:11 -07:00
GareArc 0a2ae4ba1d Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-21 01:35:59 -07:00
GareArc 058fa95517 feat: expose enterprise_enabled and gate the community edition tip on it
The sandbox isolation caveat was suppressed by inspecting license.status,
which answers a different question: a lapsed enterprise licence would start
showing a community edition warning that still does not apply, because sandbox
isolation is a property of how the deployment was built, not of billing state.

ENTERPRISE_ENABLED already drives branding, webapp auth and the plugin manager
in get_system_features, but was never surfaced, leaving the frontend to infer
the edition from side effects. Expose it alongside rbac_enabled and read it
directly, which drops the license status set entirely.
2026-07-21 01:34:28 -07:00
GareArc 75cb8ea408 refactor(api): reuse the roster service's agent resolution for RBAC checks
The app-scoped RBAC gate hand-rolled a query to turn an agent_id into the id
of the App backing it, duplicating AgentRosterService.runtime_backing_app_id.
Worse, it filtered on id and tenant only, so the auth check accepted archived
Agents and sources the runtime resolver rejects -- a weaker notion of a valid
Agent than the layer it guards.

Add peek_runtime_backing_app_id, a read-only counterpart to
get_agent_runtime_app_model that never materializes the hidden backing App,
and share the eligibility filter between the two so they cannot drift.

The tenant filter was also untested: deleting it left all 50 tests passing,
since the specs stubbed the db module wholesale. Assert the resolver is called
with the caller's tenant instead, and cover the precedence and read-only
guarantees against the service directly.
2026-07-21 01:34:16 -07:00
GareArc 457e1e9187 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-21 00:22:42 -07:00
GareArc edbaaeae23 fix(api): resolve agent routes to their backing app for app-scoped RBAC 2026-07-21 00:22:24 -07:00
GareArc 79370e8c75 fix(web): hide the Agent Console link without agent.manage 2026-07-21 00:22:23 -07:00
GareArc c039a15014 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 22:55:37 -07:00
GareArc 66473d5d51 refactor(web): extract CommunityEditionTip and gate the build-mode caveat 2026-07-20 22:55:16 -07:00
GareArc 024ba2571e Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 21:57:26 -07:00
GareArc 4626a9bc59 fix(web): hide the sandbox isolation disclaimer under an enterprise license 2026-07-20 21:57:09 -07:00
GareArc 51e985665f Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 20:46:31 -07:00
GareArc 7037f2acc5 fix(web): add agent.manage permission label to all locales 2026-07-20 20:45:20 -07:00
GareArc 13063dbc10 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 19:27:50 -07:00
GareArc 5e48e39d10 fix(web): stop exporting the unused agent.manage key constant 2026-07-20 18:28:21 -07:00
GareArc e606156580 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-20 03:47:12 -07:00
GareArc c56ff84565 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 02:42:28 -07:00
GareArc 525e76b577 test: port agent RBAC specs to the console state fixtures
main replaced the dataset-access atom mocks with @/test/console
fixtures (#39239), which landed after this branch was cut. Move the
agents access guard spec onto createWorkspaceStateModuleMock /
createPermissionStateModuleMock and update the main nav additions to
mockConsoleState.
2026-07-20 02:42:01 -07:00
GareArc 181f58ed9c Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-20 02:31:18 -07:00
GareArc f947cbfffb test: cover the agents access guard and unbreak the roster layout suite
AgentsAccessGuard calls useRouter, which threw "invariant expected app
router to be mounted" in the existing roster layout suite. Mock the guard
there so that suite stays scoped to the feature flag, and give the guard
its own spec covering loading, redirect and pass-through.

Also assert the disabled edit-in-console control through its accessible
role, and move the legacy-key test into a class like its neighbours.
2026-07-20 02:09:31 -07:00
GareArc 87e7b5660a refactor(web): build edit-in-console control from the Button primitive
The enabled and disabled states each carried a hand-written copy of the
secondary-button class list, and the disabled state faked a button with
an aria-disabled span. Render both from dify-ui Button instead, which
already ships the secondary variant and its disabled styling.
2026-07-20 01:49:16 -07:00
GareArc 8440564007 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-20 01:19:17 -07:00
GareArc 582ddbce43 refactor(web): make main nav visibility a predicate
The visibility string union resolved through an if-ladder whose final
branch was an unlabeled fallthrough, so an unrecognized value silently
resolved to the app-deploy check. Replace it with a predicate on the
route config plus named shared predicates, and drop the unused
notDatasetOperator case.
2026-07-20 00:59:55 -07:00
yyh 222169e8d5 Merge remote-tracking branch 'origin/main' into feat/agentv2-webapp-access-control 2026-07-20 15:41:04 +08:00
yyh ac63b8c671 test(web): use complete system feature fixture 2026-07-20 15:36:56 +08:00
yyh ffe318906b fix: lint 2026-07-20 15:30:30 +08:00
yyh 787cda9ee1 fix(web): use radio semantics for app access modes 2026-07-20 15:22:38 +08:00
yyh 6b8b27962d fix(web): preserve app access selection on rerender 2026-07-20 15:19:54 +08:00
yyh 1e5a07ba21 perf(web): lazy load agent access control dialog 2026-07-20 15:19:20 +08:00
yyh 509f92ef2d refactor(web): keep app ACL state reads local 2026-07-20 15:18:37 +08:00
yyh ebe83f3976 test(web): seed agent access system features 2026-07-20 15:17:03 +08:00
yyh f2ba0a7c08 refactor(web): suspend on agent system features 2026-07-20 15:15:39 +08:00
yyh 816c6cad82 refactor(web): centralize web app access cache updates 2026-07-20 15:14:19 +08:00
yyh 4ae9028d4a fix(web): target agent backing app access control 2026-07-20 15:06:00 +08:00
yyh 2ee957bf49 fix(web): guard invalid agent access modes 2026-07-20 15:05:28 +08:00
GareArc 1f9ffc1680 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:51:58 -07:00
GareArc 96f63dfb1e refactor(web): drop access-mode map extraction no longer needed by agent card 2026-07-19 23:51:53 -07:00
GareArc 37b720fd1d Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:40:42 -07:00
GareArc 2ef1b05afc Revert "fix(web): split agent access cards by container width instead of viewport breakpoint"
This reverts commit 3c460a3bec.
2026-07-19 23:40:29 -07:00
GareArc 1215fb7c25 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:38:29 -07:00
GareArc 3c460a3bec fix(web): split agent access cards by container width instead of viewport breakpoint 2026-07-19 23:38:23 -07:00
GareArc a14a746931 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:19:02 -07:00
GareArc 03a005ce2c feat(web): move agent webapp access control entry into action row button 2026-07-19 23:18:48 -07:00
GareArc dac931b5f1 feat(web): disable edit-in-console link without agent.manage 2026-07-19 23:16:55 -07:00
GareArc e284ee15a1 feat(web): redirect non-holders away from agents routes 2026-07-19 23:16:54 -07:00
GareArc 9a3968aba0 feat(web): gate Agents nav entry behind agent.manage 2026-07-19 23:10:09 -07:00
GareArc 3e8b14f8e0 feat(api): require agent.manage on module-exclusive agent endpoints 2026-07-19 23:07:21 -07:00
GareArc 5c42d0928b feat(api): add agent.manage RBAC permission and legacy fallback keys 2026-07-19 23:04:12 -07:00
GareArc d34f3af9c3 Merge remote-tracking branch 'origin/main' into deploy/enterprise 2026-07-19 22:57:47 -07:00
GareArc ae41e8d267 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 21:25:39 -07:00
GareArc 48c347eeb7 feat(web): add webapp access control to agent access card 2026-07-19 21:16:18 -07:00
GareArc afc9109148 refactor(web): export access mode maps, narrow AccessControl app prop 2026-07-19 20:54:34 -07:00
329 changed files with 3026 additions and 5864 deletions
-5
View File
@@ -1497,11 +1497,6 @@ 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,
+29 -2
View File
@@ -1,8 +1,17 @@
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
from pydantic import (
Field,
NonNegativeFloat,
NonNegativeInt,
PositiveFloat,
PositiveInt,
computed_field,
model_validator,
)
from pydantic_settings import BaseSettings
from .cache.redis_config import RedisConfig
@@ -408,4 +417,22 @@ class MiddlewareConfig(
DatasetQueueMonitorConfig,
MatrixoneConfig,
):
pass
@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
+32 -4
View File
@@ -1,4 +1,4 @@
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator, model_validator
from pydantic_settings import BaseSettings
@@ -42,6 +42,13 @@ 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",
@@ -158,12 +165,33 @@ 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_MAX_CONNECTIONS", mode="before")
@field_validator(
"REDIS_SSL_CA_CERTS",
"REDIS_SSL_CERTFILE",
"REDIS_SSL_KEYFILE",
"REDIS_MAX_CONNECTIONS",
mode="before",
)
@classmethod
def _empty_string_to_none_for_max_conns(cls, v):
"""Allow empty string in env/.env to mean 'unset' (None)."""
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``.
"""
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,6 +23,7 @@ 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)
@@ -35,6 +36,7 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_plugin_model_providers",
"_runtime_credentials",
"_workspace",
"api",
@@ -0,0 +1,39 @@
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
+1 -14
View File
@@ -1,7 +1,7 @@
import inspect
import json
import logging
from collections.abc import Callable, Generator, Mapping
from collections.abc import Callable, Generator
from typing import Any, cast
from urllib.parse import unquote
@@ -23,7 +23,6 @@ from core.plugin.impl.exc import (
PluginLLMPollingUnsupportedError,
PluginNotFoundError,
PluginPermissionDeniedError,
PluginRuntimeError,
PluginUniqueIdentifierError,
)
from core.trigger.errors import (
@@ -376,18 +375,6 @@ 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,18 +49,6 @@ 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
@@ -0,0 +1,58 @@
"""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,
)
+17 -4
View File
@@ -1,3 +1,4 @@
import logging
import ssl
from datetime import timedelta
from typing import Any
@@ -9,9 +10,12 @@ 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
@@ -108,10 +112,12 @@ 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=dify_config.CELERY_BROKER_URL,
broker=broker_url,
backend=dify_config.CELERY_BACKEND,
)
@@ -128,9 +134,12 @@ def init_app(app: DifyApp) -> Celery:
)
if dify_config.CELERY_BACKEND == "redis":
celery_app.conf.update(
result_backend_transport_options=broker_transport_options,
)
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)
# Apply SSL configuration if enabled
ssl_options = get_celery_ssl_options()
@@ -141,6 +150,10 @@ 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,
+14 -1
View File
@@ -21,6 +21,7 @@ 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,
@@ -438,6 +439,10 @@ 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
@@ -457,12 +462,20 @@ 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)
@@ -486,7 +499,7 @@ def init_app(app: DifyApp):
global _pubsub_redis_client
_pubsub_redis_client = client
if dify_config.normalized_pubsub_redis_url:
if dify_config.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,8 +9,6 @@ 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
@@ -102,20 +100,6 @@ 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)
@@ -137,7 +121,6 @@ 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,12 +17318,6 @@ Default model entity.
| tool_name | string | | Yes |
| type | string | | Yes |
#### DeploymentEdition
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | | |
#### DismissNotificationPayload
| Name | Type | Description | Required |
@@ -22032,7 +22026,6 @@ 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,12 +1058,6 @@ 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 |
@@ -1573,7 +1567,6 @@ 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,6 +21,7 @@ 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
+1 -17
View File
@@ -75,12 +75,6 @@ class LicenseStatus(StrEnum):
LOST = "lost"
class DeploymentEdition(StrEnum):
COMMUNITY = "COMMUNITY"
ENTERPRISE = "ENTERPRISE"
CLOUD = "CLOUD"
class LicenseModel(FeatureResponseModel):
status: LicenseStatus = LicenseStatus.NONE
expired_at: str = ""
@@ -168,7 +162,6 @@ 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 = ""
@@ -259,7 +252,7 @@ class FeatureService:
@classmethod
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel(deployment_edition=cls._resolve_deployment_edition())
system_features = SystemFeatureModel()
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
@@ -279,14 +272,6 @@ 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
@@ -300,7 +285,6 @@ 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,7 +414,6 @@ 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"
@@ -617,7 +616,6 @@ 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 DeploymentEdition, SystemFeatureModel, WebAppAuthModel
from services.feature_service import SystemFeatureModel, WebAppAuthModel
def _unwrap(func):
@@ -47,10 +47,7 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
webapp_auth=WebAppAuthModel(enabled=enabled),
)
features = SystemFeatureModel(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 DeploymentEdition, SystemFeatureModel
from services.feature_service import SystemFeatureModel
class TestEmailRegisterSendEmailApi:
@@ -34,11 +34,7 @@ class TestEmailRegisterSendEmailApi:
mock_account = MagicMock()
mock_get_account.return_value = mock_account
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
feature_flags = SystemFeatureModel(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"),
@@ -79,11 +75,7 @@ class TestEmailRegisterCheckApi:
mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
feature_flags = SystemFeatureModel(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),
@@ -131,11 +123,7 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
feature_flags = SystemFeatureModel(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),
@@ -183,11 +171,7 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
feature_flags = SystemFeatureModel(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),
@@ -240,11 +224,7 @@ class TestEmailRegisterResetApi:
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
feature_flags = SystemFeatureModel(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 DeploymentEdition, SystemFeatureModel
from services.feature_service import SystemFeatureModel
@pytest.fixture
@@ -46,15 +46,8 @@ class TestForgotPasswordSendEmailApi:
mock_get_account.return_value = mock_account
mock_send_email.return_value = "token-123"
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,
)
wraps_features = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True)
controller_features = SystemFeatureModel(is_allow_register=True)
with (
patch(
"controllers.console.auth.forgot_password.FeatureService.get_system_features",
@@ -102,10 +95,7 @@ class TestForgotPasswordCheckApi:
mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"}
mock_generate_token.return_value = (None, "new-token")
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
wraps_features = SystemFeatureModel(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),
@@ -148,10 +138,7 @@ class TestForgotPasswordResetApi:
db.session.commit()
mock_get_account.return_value = account
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
wraps_features = SystemFeatureModel(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 DeploymentEdition, SystemFeatureModel
from services.feature_service import SystemFeatureModel
SQLITE_MODELS = (Account, Tenant, TenantAccountJoin)
@@ -48,10 +48,7 @@ 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(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
),
lambda: SystemFeatureModel(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 DeploymentEdition, FeatureModel, LimitationModel, SystemFeatureModel
from services.feature_service import FeatureModel, LimitationModel, SystemFeatureModel
def make_account() -> Account:
@@ -94,11 +94,7 @@ class TestSystemFeatureApi:
"controllers.console.feature.current_account_with_tenant_optional",
return_value=(account, "tenant-123"),
)
system_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=True,
enable_learn_app=True,
)
system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
@@ -123,10 +119,7 @@ class TestSystemFeatureApi:
"controllers.console.feature.current_account_with_tenant_optional",
return_value=(None, None),
)
system_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=False,
)
system_features = SystemFeatureModel(is_allow_register=False)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
@@ -0,0 +1,64 @@
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 DeploymentEdition, SystemFeatureModel
from services.feature_service import SystemFeatureModel
@pytest.fixture
@@ -32,10 +32,7 @@ def database_app() -> Iterator[Flask]:
@pytest.fixture(autouse=True)
def _patch_wraps():
wraps_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
)
wraps_features = SystemFeatureModel(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, PluginRuntimeError
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@@ -175,25 +175,3 @@ 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
@@ -0,0 +1,186 @@
"""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,7 +4,6 @@ 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
@@ -40,14 +39,6 @@ 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
@@ -116,24 +107,6 @@ 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,7 +24,6 @@ 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,
@@ -36,11 +35,10 @@ 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,
),
)
)
@@ -1,25 +0,0 @@
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
@@ -1,34 +0,0 @@
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 DeploymentEdition, FeatureService, SystemFeatureModel
from services.feature_service import FeatureService, SystemFeatureModel
@pytest.mark.parametrize(
@@ -29,7 +29,7 @@ def test_fulfill_params_from_enterprise_enable_app_deploy(
staticmethod(lambda: enterprise_info),
)
features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
features = SystemFeatureModel()
features.enable_app_deploy = initial
FeatureService._fulfill_params_from_enterprise(features)
@@ -1,10 +1,10 @@
import pytest
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_disables_knowledge_fs_by_default() -> None:
assert SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY).knowledge_fs_enabled is False
assert SystemFeatureModel().knowledge_fs_enabled is False
@pytest.mark.parametrize("enabled", [False, True])
@@ -1,14 +1,12 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import DeploymentEdition, FeatureService, SystemFeatureModel
from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_defaults_enable_learn_app():
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
assert SystemFeatureModel().enable_learn_app is True
assert SystemFeatureModel().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 DeploymentEdition, FeatureService, SystemFeatureModel
from services.feature_service import 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(deployment_edition=DeploymentEdition.COMMUNITY)
features = SystemFeatureModel()
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(deployment_edition=DeploymentEdition.COMMUNITY)
features = SystemFeatureModel()
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 DeploymentEdition, FeatureService, SystemFeatureModel
from services.feature_service import 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(deployment_edition=DeploymentEdition.COMMUNITY)
system_features = SystemFeatureModel()
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 DeploymentEdition, SystemFeatureModel
from services.feature_service import SystemFeatureModel
from services.recommended_app_service import RecommendedAppService
pytestmark = pytest.mark.parametrize(
@@ -298,10 +298,7 @@ class TestRecommendedAppServiceGetDetail:
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
cases: list[tuple[str, RecommendedAppPayload]] = [
(
"complex-app",
@@ -340,10 +337,7 @@ class TestRecommendedAppServiceGetDetail:
mock_feature_service: MagicMock,
sqlite_session: Session,
) -> None:
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(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}")
@@ -373,10 +367,7 @@ class TestRecommendedAppServiceGetLearnDifyApps:
sqlite_session: Session,
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False)
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_instance = MagicMock()
mock_instance.get_learn_dify_apps.return_value = {
@@ -411,12 +402,7 @@ class TestRecommendedAppServiceGetLearnDifyApps:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
)
can_trial_mock = MagicMock(return_value=True)
monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock)
@@ -439,12 +425,7 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=False,
)
),
MagicMock(return_value=SystemFeatureModel(enable_trial_app=False)),
)
result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)
@@ -475,12 +456,7 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
)
result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session)
@@ -516,12 +492,7 @@ class TestRecommendedAppServiceTrialFeatures:
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(
return_value=SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_trial_app=True,
)
),
MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)),
)
result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
Generated
+18
View File
@@ -1371,6 +1371,7 @@ dependencies = [
{ name = "python-socketio" },
{ name = "readabilipy" },
{ name = "redis", extra = ["hiredis"] },
{ name = "redis-entraid" },
{ name = "resend" },
{ name = "sendgrid" },
{ name = "sseclient-py" },
@@ -1655,6 +1656,7 @@ 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" },
@@ -5819,6 +5821,22 @@ 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,6 +121,7 @@ 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
+2 -14
View File
@@ -16,21 +16,11 @@ 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
ALLOW_REGISTER=false
ALLOW_CREATE_WORKSPACE=false
ENABLE_CHANGE_EMAIL=true
ENABLE_TRIAL_APP=false
ENABLE_EXPLORE_BANNER=false
# Learn app feature toggle
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
@@ -100,8 +90,6 @@ 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,6 +15,7 @@ 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,6 +835,14 @@
"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,7 +6,6 @@ export type ClientOptions = {
export type SystemFeatureModel = {
branding: BrandingModel
deployment_edition: DeploymentEdition
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
@@ -41,8 +40,6 @@ export type BrandingModel = {
workspace_logo: string
}
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
export type LicenseModel = {
expired_at: string
seats: LicenseLimitationModel
@@ -13,11 +13,6 @@ export const zBrandingModel = z.object({
workspace_logo: z.string().default(''),
})
/**
* DeploymentEdition
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* PluginManagerModel
*/
@@ -109,7 +104,6 @@ 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,8 +118,6 @@ export type ConversationRenamePayload = (
name?: string | null
}
export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE'
export type EmailCodeLoginSendPayload = {
email: string
language?: string | null
@@ -512,7 +510,6 @@ export type SuggestedQuestionsResponse = {
export type SystemFeatureModel = {
branding: BrandingModel
deployment_edition: DeploymentEdition
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
@@ -132,11 +132,6 @@ export const zConversationRenamePayload = z.intersection(
}),
)
/**
* DeploymentEdition
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* EmailCodeLoginSendPayload
*/
@@ -787,7 +782,6 @@ 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,5 +1,9 @@
# 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.
@@ -110,3 +114,20 @@ 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,6 +49,7 @@ 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,5 +1,3 @@
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'
@@ -16,21 +14,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 { 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 })
}
import { render } from '@/test/console/render'
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,5 +1,3 @@
import type { RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
/**
* Integration test: Education Verification Flow
*
@@ -16,15 +14,7 @@ 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 { 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 })
}
import { render } from '@/test/console/render'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
@@ -35,6 +25,14 @@ 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,15 +7,11 @@
* Covers URL param reading, cookie persistence, API bind on mount,
* cookie cleanup after successful bind, and error handling for 400 status.
*/
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import * as React from 'react'
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
import { PARTNER_STACK_CONFIG } from '@/config'
import { renderWithConsoleQuery } from '@/test/console/query-data'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockSearchParams = new URLSearchParams()
@@ -43,6 +39,7 @@ 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,
@@ -291,7 +288,7 @@ describe('Partner Stack Flow', () => {
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
describe('PartnerStack component mount behavior', () => {
it('should call saveOrUpdate and bind on mount', async () => {
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'mount-partner',
ps_xid: 'mount-click',
@@ -302,13 +299,18 @@ 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,5 +1,6 @@
describe('env runtime transport', () => {
const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
const originalRbacEnv = process.env.NEXT_PUBLIC_RBAC_ENABLED
beforeEach(() => {
vi.clearAllMocks()
@@ -7,12 +8,17 @@ 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 () => {
@@ -23,6 +29,14 @@ 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'
@@ -37,4 +51,18 @@ 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,7 +2,6 @@ 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']
@@ -27,9 +26,7 @@ const createDeferred = <T,>(): Deferred<T> => {
const mocks = vi.hoisted(() => ({
profileQuery: undefined as Deferred<{ id: string }> | undefined,
systemFeaturesQuery: undefined as
| Deferred<ReturnType<typeof createSystemFeaturesFixture>>
| undefined,
systemFeaturesQuery: undefined as Deferred<{ branding: { enabled: boolean } }> | undefined,
}))
vi.mock('@/features/account-profile/client', () => ({
@@ -39,24 +36,12 @@ vi.mock('@/features/account-profile/client', () => ({
}),
}))
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,
}),
},
},
},
}
})
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: systemFeaturesQueryKey,
queryFn: () => mocks.systemFeaturesQuery!.promise,
}),
}))
function createQueryClient() {
return new QueryClient({
@@ -93,7 +78,7 @@ describe('ConsoleBootstrapGate', () => {
expect(screen.queryByText('Console shell')).not.toBeInTheDocument()
await act(async () => {
mocks.systemFeaturesQuery!.resolve(createSystemFeaturesFixture())
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
})
expect(await screen.findByText('Console shell')).toBeInTheDocument()
@@ -102,9 +87,11 @@ 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, createSystemFeaturesFixture(), {
updatedAt: 1,
})
queryClient.setQueryData(
systemFeaturesQueryKey,
{ branding: { enabled: false } },
{ updatedAt: 1 },
)
renderGate(<div>Console shell</div>, queryClient)
@@ -115,7 +102,7 @@ describe('ConsoleBootstrapGate', () => {
await act(async () => {
mocks.profileQuery!.reject(new Error('profile refetch failed'))
mocks.systemFeaturesQuery!.resolve(createSystemFeaturesFixture())
mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } })
})
await waitFor(() => {
@@ -1,11 +1,10 @@
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(() => ({
rootQueryClient: undefined as QueryClient | undefined,
queryClient: undefined as QueryClient | undefined,
profileQueryFn: vi.fn(),
systemFeaturesQueryFn: vi.fn(),
workspaceQueryFn: vi.fn(),
@@ -19,14 +18,9 @@ const mocks = vi.hoisted(() => ({
basePath: '',
}))
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('@/context/query-client-server', () => ({
getQueryClientServer: () => mocks.queryClient,
}))
vi.mock('@/next/headers', () => ({
headers: () => mocks.headers(),
@@ -50,14 +44,6 @@ 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),
@@ -72,11 +58,19 @@ 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.rootQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.headers.mockResolvedValue(
new Headers({
'x-dify-pathname': '/apps',
@@ -113,7 +107,7 @@ describe('CommonLayoutHydrationBoundary', () => {
})
})
it('should prefetch common layout queries without requesting System Features', async () => {
it('should prefetch common layout queries and render children', async () => {
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
@@ -127,7 +121,7 @@ describe('CommonLayoutHydrationBoundary', () => {
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.getServerConsoleClientContext).toHaveBeenCalledTimes(1)
expect(mocks.workspaceQueryOptions).toHaveBeenCalledWith({
context: {
@@ -139,25 +133,6 @@ 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,6 +1,5 @@
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { AppACLPermission } from '@/utils/permission'
import ChartView from '../chart-view'
@@ -14,7 +13,6 @@ const testState = vi.hoisted(() => ({
currentUserId: 'user-1',
workspacePermissionKeys: [] as string[],
chartRenderSpy: vi.fn(),
conversationPeriodSpy: vi.fn(),
}))
vi.mock('@/context/workspace-state', async () => {
@@ -48,9 +46,8 @@ vi.mock('@/app/components/app/overview/app-chart', () => ({
testState.chartRenderSpy('avg-user-interactions')
return <div>avg user interactions chart</div>
},
ConversationsChart: ({ period }: { period: PeriodParams }) => {
ConversationsChart: () => {
testState.chartRenderSpy('conversations')
testState.conversationPeriodSpy(period)
return <div>conversations chart</div>
},
CostChart: () => {
@@ -1,8 +1,6 @@
'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'
@@ -25,10 +23,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'
@@ -53,28 +51,7 @@ 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)
@@ -90,8 +67,8 @@ function ChartViewContent({
)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(() =>
isCloudEdition
const [period, setPeriod] = useState<PeriodParams>(
IS_CLOUD_EDITION
? {
name: t(($) => $['filter.period.today'], { ns: 'appLog' }),
query: {
@@ -135,14 +112,13 @@ function ChartViewContent({
</div>
</div>
<div className="mt-1 flex h-10 items-center justify-between pr-10 pl-6">
{isCloudEdition && (
{IS_CLOUD_EDITION ? (
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
)}
{isNonCloudEdition && (
) : (
<LongTimeRangePicker
periodMapping={LONG_TIME_PERIOD_MAPPING}
onSelect={setPeriod}
@@ -43,6 +43,14 @@ 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,7 +2,6 @@
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'
@@ -14,8 +13,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'
@@ -61,10 +60,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const { data: isRbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ rbac_enabled }) => rbac_enabled,
})
const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom)
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', '/datasets/new/create'])(
it.each(['/datasets/create', '/datasets/create-from-pipeline'])(
'should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s',
async (pathname) => {
mockPathname = pathname
@@ -186,22 +186,6 @@ 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,7 +17,6 @@ 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/')
)
@@ -1,5 +0,0 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="documents" />
}
@@ -1,18 +0,0 @@
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>
)
}
@@ -1,6 +0,0 @@
import { redirect } from '@/next/navigation'
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
const { knowledgeSpaceId } = await params
redirect(`/datasets/new/${knowledgeSpaceId}/sources`)
}
@@ -1,5 +0,0 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="sources" />
}
@@ -1,10 +0,0 @@
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>
)
}
+1 -10
View File
@@ -1,7 +1,6 @@
'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'
@@ -13,7 +12,6 @@ type Props = Readonly<{
export default function CommonLayoutError({ error, unstable_retry }: Props) {
const { t } = useTranslation('common')
const { reset } = useQueryErrorResetBoundary()
console.error(error)
@@ -28,14 +26,7 @@ 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={() => {
reset()
unstable_retry()
}}
>
<Button size="small" variant="secondary" onClick={() => unstable_retry()}>
{t(($) => $['errorBoundary.tryAgain'])}
</Button>
</div>
@@ -1,7 +1,8 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { makeQueryClient } from '@/context/query-client-server'
import { getQueryClientServer } 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 {
@@ -55,7 +56,7 @@ const handleProfileError = async (error: unknown) => {
}
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = makeQueryClient()
const queryClient = getQueryClientServer()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (accountProfileUrl) {
@@ -64,6 +65,7 @@ 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,6 +6,7 @@ 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'
@@ -18,9 +19,6 @@ 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)
@@ -238,7 +236,7 @@ const NormalForm = () => {
{t(($) => $.pp, { ns: 'login' })}
</Link>
</div>
{isNonCloudEdition && (
{IS_CE_EDITION && (
<div className="w-hull mt-2 block system-xs-regular text-text-tertiary">
{t(($) => $.goToInit, { ns: 'login' })}
&nbsp;
-39
View File
@@ -1,39 +0,0 @@
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
@@ -1,69 +0,0 @@
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 { validPassword } from '@/config'
import { IS_CE_EDITION, 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"
/>
)}
{systemFeatures.deployment_edition === 'CLOUD' && (
{!IS_CE_EDITION && (
<Button
className="mt-2 text-components-button-destructive-secondary-text"
onClick={() => setShowDeleteAccountModal(true)}
+10 -2
View File
@@ -4,12 +4,16 @@ 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', () => ({}))
@@ -48,6 +52,7 @@ 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 () => {
@@ -239,7 +244,8 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
})
it('should keep a staging fallback on the current deployment after refresh', async () => {
it('should keep a Cloud staging fallback on the current deployment after refresh', async () => {
mocks.isCloudEdition = true
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const { GET } = await import('../route')
@@ -254,7 +260,8 @@ describe('auth refresh route', () => {
expect(response.headers.get('location')).toBe('/')
})
it('should carry the current deployment fallback through signin when refresh fails', async () => {
it('should carry the current Cloud deployment fallback through signin when refresh fails', async () => {
mocks.isCloudEdition = true
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
@@ -270,6 +277,7 @@ 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,10 +6,17 @@ 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(),
}))
@@ -36,6 +43,7 @@ describe('ExternalAttributionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove('utm_info')
mockConfig.IS_CLOUD_EDITION = true
setAnalyticsConsent('granted')
setSearchParams()
})
@@ -142,4 +150,14 @@ 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,15 +35,7 @@ vi.mock('@/service/access-control', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
get: {
queryKey: () => ['system-features'],
queryOptions: (options: Record<string, unknown> = {}) => ({
queryKey: ['system-features'],
...options,
}),
},
},
systemFeatures: { get: { queryKey: () => ['system-features'] } },
enterprise: {
webAppAuth: {
updateWebAppWhitelistSubjects: {
@@ -1,8 +1,7 @@
/* oxlint-disable typescript/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, 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,11 +3,10 @@ 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, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, 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,11 +1,10 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, 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,6 +8,7 @@ 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,
@@ -17,7 +18,6 @@ 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, createSystemFeaturesFixture())
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, defaultSystemFeatures)
return render(
<QueryClientProvider client={queryClient}>
@@ -1,18 +1,22 @@
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 } from '@/test/console/query-data'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() }))
const render = (ui: ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
const mockConfig = vi.hoisted(() => ({ isCloudEdition: true }))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
}))
const app: App = {
can_trial: true,
@@ -43,6 +47,7 @@ const app: App = {
describe('AppCard', () => {
beforeEach(() => {
mockConfig.isCloudEdition = true
vi.clearAllMocks()
})
@@ -4,14 +4,13 @@ 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 = {
@@ -22,12 +21,8 @@ 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 = deploymentEdition === 'CLOUD'
const canViewApp = IS_CLOUD_EDITION
const setShowTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.setShowTryAppPanel)
const handleShowTryAppPanel = useCallback(() => {
trackEvent('preview_template', {
@@ -3,26 +3,27 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import InSiteMessageNotification from '../notification'
const { mockEdition, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
mockEdition: {
value: 'CLOUD' as 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' | null,
const { mockConfig, mockNotification, mockNotificationDismiss } = vi.hoisted(() => ({
mockConfig: {
isCloudEdition: true,
},
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>) => ({
@@ -55,9 +56,6 @@ const createWrapper = () => {
},
},
})
queryClient.setQueryData(['console', 'systemFeatures', 'get'], {
deployment_edition: mockEdition.value,
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
@@ -69,7 +67,7 @@ const createWrapper = () => {
describe('InSiteMessageNotification', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEdition.value = 'CLOUD'
mockConfig.isCloudEdition = true
vi.stubGlobal('open', vi.fn())
})
@@ -80,7 +78,7 @@ describe('InSiteMessageNotification', () => {
// Validate query gating and empty state rendering.
describe('Rendering', () => {
it('should render null and skip query when not cloud edition', async () => {
mockEdition.value = 'COMMUNITY'
mockConfig.isCloudEdition = false
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
@@ -1,9 +1,9 @@
'use client'
import type { InSiteMessageActionItem } from './index'
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { IS_CLOUD_EDITION } from '@/config'
import { consoleQuery } from '@/service/client'
import InSiteMessage from './index'
@@ -56,25 +56,20 @@ 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: isCloudEdition,
enabled: IS_CLOUD_EDITION,
}),
)
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
if (!isCloudEdition || !notification || !notification.notification_id) return null
if (!IS_CLOUD_EDITION || !notification || !notification.notification_id) return null
const notificationId = notification.notification_id
const fallbackActions: InSiteMessageActionItem[] = [
@@ -5,10 +5,17 @@ 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')
@@ -50,12 +57,6 @@ 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()
@@ -68,7 +69,7 @@ describe('ArchivedLogsNotice', () => {
})
it('should show notice for paid workspace managers', () => {
renderNotice()
render(<ArchivedLogsNotice />)
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
@@ -80,7 +81,7 @@ describe('ArchivedLogsNotice', () => {
it('should not show notice for sandbox workspaces', () => {
mockProviderPlan(Plan.sandbox)
renderNotice()
render(<ArchivedLogsNotice />)
expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument()
})
@@ -1,21 +1,16 @@
'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(
@@ -23,7 +18,7 @@ export function ArchivedLogsNotice() {
)
if (
deploymentEdition !== 'CLOUD' ||
!IS_CLOUD_EDITION ||
!isCurrentWorkspaceManager ||
!enableBilling ||
plan.type === Plan.sandbox
@@ -313,7 +313,6 @@ 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,9 +5,10 @@ import {
interactions,
mockUseModalContext,
scenarios,
setDeploymentEdition,
} from './test-utils'
vi.mock('@/config', () => ({ IS_CE_EDITION: false }))
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
@@ -15,7 +16,6 @@ describe('APIKeyInfoPanel - Cloud Edition', () => {
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('CLOUD')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
@@ -5,10 +5,11 @@ import {
interactions,
mockUseModalContext,
scenarios,
setDeploymentEdition,
textKeys,
} from './test-utils'
vi.mock('@/config', () => ({ IS_CE_EDITION: true }))
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
@@ -16,7 +17,6 @@ describe('APIKeyInfoPanel - Community Edition', () => {
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('COMMUNITY')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
@@ -1,8 +1,7 @@
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, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import {
@@ -10,7 +9,6 @@ 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(() => ({
@@ -104,7 +102,6 @@ type APIKeyInfoPanelRenderOptions = {
} & Omit<RenderOptions, 'wrapper'>
const mainButtonName = /appOverview\.apiKeyInfo\.setAPIBtn/
let deploymentEdition: DeploymentEdition = 'COMMUNITY'
// Setup function to configure mocks
function setupMocks(overrides: MockOverrides = {}) {
@@ -132,10 +129,7 @@ function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
setupMocks(mockOverrides)
return renderWithConsoleQuery(<APIKeyInfoPanel />, {
...renderOptions,
systemFeatures: { deployment_edition: deploymentEdition },
})
return render(<APIKeyInfoPanel />, renderOptions)
}
// Helper functions for common test scenarios
@@ -206,9 +200,5 @@ export function clearAllMocks() {
vi.clearAllMocks()
}
export function setDeploymentEdition(value: DeploymentEdition) {
deploymentEdition = value
}
// Export mock functions for external access
export { defaultModalContext, mockUseModalContext }
@@ -3,22 +3,17 @@ 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 { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const isCloud = deploymentEdition === 'CLOUD'
const isCloud = !IS_CE_EDITION
const { isAPIKeySet } = useProviderContext()
const openIntegrationsSetting = useIntegrationsSetting()
@@ -2,6 +2,7 @@ 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'
@@ -176,16 +177,20 @@ export const getEmbeddedScriptSnippet = ({
primaryColor: string
isTestEnv?: boolean
inputValues: Record<string, WorkflowLaunchInputValue>
}) => {
return `<script>
}) =>
`<script>
window.difyChatbotConfig = {
token: '${token}'${
isTestEnv
? `,
isDev: true`
: ''
},
baseUrl: '${url}${basePath}'${
}${
IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''
}${
webAppRoute !== 'chatbot'
? `,
routeSegment: '${webAppRoute}'`
@@ -216,7 +221,6 @@ export const getEmbeddedScriptSnippet = ({
height: 40rem !important;
}
</style>`
}
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
@@ -105,10 +105,6 @@ vi.mock('@/service/client', () => ({
systemFeatures: {
get: {
queryKey: () => ['console', 'systemFeatures', 'get'],
queryOptions: (options: Record<string, unknown> = {}) => ({
queryKey: ['console', 'systemFeatures', 'get'],
...options,
}),
},
},
},
@@ -5,6 +5,7 @@ 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',
@@ -16,6 +17,12 @@ 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', () => ({
@@ -37,6 +44,7 @@ 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,6 +4,7 @@ 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
@@ -12,6 +13,12 @@ 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', () => ({
@@ -29,6 +36,7 @@ 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,6 +2,7 @@ import { flushEvents, resetUser, setUserId, setUserProperties, trackEvent } from
const mockState = vi.hoisted(() => ({
consent: 'granted' as 'unknown' | 'denied' | 'granted',
enabled: true,
initialized: true,
}))
@@ -23,6 +24,12 @@ const MockIdentify = vi.hoisted(
},
)
vi.mock('@/config', () => ({
get isAmplitudeEnabled() {
return mockState.enabled
},
}))
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
getAnalyticsConsent: () => mockState.consent,
}))
@@ -44,11 +51,12 @@ 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 the consented SDK is initialized', () => {
it('should call amplitude.track and return its result when amplitude is enabled', () => {
const trackResult = { promise: Promise.resolve({}) }
mockTrack.mockReturnValue(trackResult)
@@ -59,8 +67,8 @@ describe('amplitude utils', () => {
expect(result).toBe(trackResult)
})
it('should not call amplitude.track before the SDK initializes', () => {
mockState.initialized = false
it('should not call amplitude.track when amplitude is disabled', () => {
mockState.enabled = false
trackEvent('dataset_created', { source: 'wizard' })
@@ -85,7 +93,7 @@ describe('amplitude utils', () => {
})
describe('flushEvents', () => {
it('should call amplitude.flush and return its result when the consented SDK is initialized', () => {
it('should call amplitude.flush and return its result when amplitude is enabled', () => {
const flushResult = { promise: Promise.resolve() }
mockFlush.mockReturnValue(flushResult)
@@ -95,8 +103,8 @@ describe('amplitude utils', () => {
expect(result).toBe(flushResult)
})
it('should not call amplitude.flush before the SDK initializes', () => {
mockState.initialized = false
it('should not call amplitude.flush when amplitude is disabled', () => {
mockState.enabled = false
flushEvents()
@@ -113,15 +121,15 @@ describe('amplitude utils', () => {
})
describe('setUserId', () => {
it('should call amplitude.setUserId when the consented SDK is initialized', () => {
it('should call amplitude.setUserId when amplitude is enabled', () => {
setUserId('user-123')
expect(mockSetUserId).toHaveBeenCalledTimes(1)
expect(mockSetUserId).toHaveBeenCalledWith('user-123')
})
it('should not call amplitude.setUserId before the SDK initializes', () => {
mockState.initialized = false
it('should not call amplitude.setUserId when amplitude is disabled', () => {
mockState.enabled = false
setUserId('user-123')
@@ -138,7 +146,7 @@ describe('amplitude utils', () => {
})
describe('setUserProperties', () => {
it('should build an identify event when the consented SDK is initialized', () => {
it('should build identify event and call amplitude.identify when amplitude is enabled', () => {
const properties = {
role: 'owner',
seats: 3,
@@ -157,8 +165,8 @@ describe('amplitude utils', () => {
])
})
it('should not call amplitude.identify before the SDK initializes', () => {
mockState.initialized = false
it('should not call amplitude.identify when amplitude is disabled', () => {
mockState.enabled = false
setUserProperties({ role: 'owner' })
@@ -175,14 +183,14 @@ describe('amplitude utils', () => {
})
describe('resetUser', () => {
it('should call amplitude.reset when the consented SDK is initialized', () => {
it('should call amplitude.reset when amplitude is enabled', () => {
resetUser()
expect(mockReset).toHaveBeenCalledTimes(1)
})
it('should not call amplitude.reset before the SDK initializes', () => {
mockState.initialized = false
it('should not call amplitude.reset when amplitude is disabled', () => {
mockState.enabled = 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 } from '@/config'
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
export type AmplitudeInitializationOptions = {
sessionReplaySampleRate?: number
@@ -61,7 +61,7 @@ const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
export const ensureAmplitudeInitialized = ({
sessionReplaySampleRate = 0.5,
}: AmplitudeInitializationOptions = {}) => {
if (!AMPLITUDE_API_KEY || isAmplitudeInitialized) return
if (!isAmplitudeEnabled || isAmplitudeInitialized) return
isAmplitudeInitialized = true
@@ -90,6 +90,6 @@ export const ensureAmplitudeInitialized = ({
}
export const setAmplitudeOptOut = (optOut: boolean) => {
if (!AMPLITUDE_API_KEY || !isAmplitudeInitialized) return
if (!isAmplitudeEnabled || !isAmplitudeInitialized) return
amplitude.setOptOut(optOut)
}
+3 -1
View File
@@ -1,8 +1,10 @@
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 = () => getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
const canUseAmplitude = () =>
isAmplitudeEnabled && getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
/**
* Track custom event
@@ -3,6 +3,7 @@ import { render } from '@testing-library/react'
type ConfigState = {
cookieYesSiteKey: string
isCloudEdition: boolean
isProd: boolean
webPrefix: string | undefined
}
@@ -10,6 +11,7 @@ type ConfigState = {
const { configState, mockHeadersGet } = vi.hoisted(() => ({
configState: {
cookieYesSiteKey: 'site-key',
isCloudEdition: true,
isProd: true,
webPrefix: 'https://cloud.dify.ai',
} as ConfigState,
@@ -20,6 +22,9 @@ vi.mock('@/config', () => ({
get COOKIEYES_SITE_KEY() {
return configState.cookieYesSiteKey
},
get IS_CLOUD_EDITION() {
return configState.isCloudEdition
},
get IS_PROD() {
return configState.isProd
},
@@ -57,7 +62,7 @@ async function renderBoundary() {
import('../cloud-analytics-boundary'),
import('../cloud-analytics-state'),
])
const state = getCloudAnalyticsBoundaryState({ get: mockHeadersGet }, 'CLOUD')
const state = getCloudAnalyticsBoundaryState({ get: mockHeadersGet })
const view = render(<CloudAnalyticsBoundary {...state} />)
return { ...view, state }
}
@@ -67,6 +72,7 @@ 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) => {
@@ -137,17 +143,4 @@ 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,10 +17,6 @@ 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'
@@ -30,7 +26,6 @@ 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,7 +1,6 @@
'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'
@@ -13,7 +12,6 @@ export function CloudAnalyticsRuntime() {
<>
<CookieYesConsentBridge />
<AmplitudeProvider active={isCloudAnalyticsPath(pathname)} />
<ExternalAttributionRecorder />
</>
)
}
@@ -1,5 +1,4 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config'
import { COOKIEYES_SITE_KEY, IS_CLOUD_EDITION, IS_PROD, WEB_PREFIX } from '@/config'
import { isCloudAnalyticsRequest } from './request-boundary'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
@@ -16,13 +15,12 @@ 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: deploymentEdition === 'CLOUD',
isCloudEdition: IS_CLOUD_EDITION,
isProd: IS_PROD,
pathname,
requestHost,
@@ -12,8 +12,14 @@ type AudioPlayerCtorArgs = [
type MockAudioPlayerInstance = {
setCallback: ReturnType<typeof vi.fn>
destroy: ReturnType<typeof vi.fn>
pauseAudio: ReturnType<typeof vi.fn>
resetMsgId: ReturnType<typeof vi.fn>
cacheBuffers: Array<ArrayBuffer>
sourceBuffer:
| {
abort: ReturnType<typeof vi.fn>
}
| undefined
}
const mockState = vi.hoisted(() => ({
@@ -25,8 +31,10 @@ const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
const MockAudioPlayer = vi.hoisted(() => {
return class MockAudioPlayerClass {
setCallback = vi.fn()
destroy = vi.fn()
pauseAudio = vi.fn()
resetMsgId = vi.fn()
cacheBuffers = [new ArrayBuffer(1)]
sourceBuffer = { abort: vi.fn() }
constructor(...args: AudioPlayerCtorArgs) {
mockAudioPlayerConstructor(...args)
@@ -124,7 +132,9 @@ describe('AudioPlayerManager', () => {
callback,
)
expect(previous!.destroy).toHaveBeenCalledTimes(1)
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
expect(previous!.cacheBuffers).toEqual([])
expect(previous!.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
expect(next).toBe(mockState.instances[1])
})
@@ -134,7 +144,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!.destroy.mockImplementation(() => {
previous!.pauseAudio.mockImplementation(() => {
throw new Error('cleanup failure')
})
@@ -142,7 +152,7 @@ describe('AudioPlayerManager', () => {
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
}).not.toThrow()
expect(previous!.destroy).toHaveBeenCalledTimes(1)
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
})
})
@@ -3,8 +3,15 @@ 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',
@@ -15,7 +22,7 @@ vi.mock('@/service/share', () => ({
type AudioEventName =
| 'ended'
| 'pause'
| 'paused'
| 'loaded'
| 'play'
| 'timeupdate'
@@ -23,7 +30,6 @@ type AudioEventName =
| 'canplay'
| 'error'
| 'sourceopen'
| 'updateend'
type AudioEventListener = () => void
@@ -45,31 +51,12 @@ 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' | 'ended' = 'closed'
readyState: 'open' | 'closed' = 'open'
sourceBuffer = new MockSourceBuffer()
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
@@ -79,15 +66,10 @@ 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()
@@ -128,7 +110,7 @@ class MockAudio {
}
class MockAudioContext {
state: 'interrupted' | 'running' | 'suspended' = 'running'
state: 'running' | 'suspended' = 'running'
destination = {}
connect = vi.fn(() => undefined)
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
@@ -139,11 +121,9 @@ class MockAudioContext {
this.state = 'running'
})
suspend = vi.fn(async () => {
suspend = vi.fn(() => {
this.state = 'suspended'
})
close = vi.fn(async () => undefined)
}
const testState = {
@@ -153,8 +133,6 @@ const testState = {
}
class MockMediaSourceCtor extends MockMediaSource {
static isTypeSupported = vi.fn(() => true)
constructor() {
super()
testState.mediaSources.push(this)
@@ -178,7 +156,6 @@ 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
@@ -215,7 +192,6 @@ describe('AudioPlayer', () => {
testState.mediaSources = []
testState.audios = []
testState.audioContexts = []
MockMediaSourceCtor.isTypeSupported.mockReturnValue(true)
Object.defineProperty(globalThis, 'Audio', {
configurable: true,
@@ -232,11 +208,6 @@ 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 })
})
@@ -257,11 +228,6 @@ 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,
@@ -290,7 +256,7 @@ describe('AudioPlayer', () => {
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
})
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
it('should notify unsupported browser when no MediaSource implementation exists', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
@@ -298,22 +264,12 @@ describe('AudioPlayer', () => {
expect(player.mediaSource).toBeNull()
expect(audio!.src).toBe('')
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()
expect(mockToastNotify).toHaveBeenCalledTimes(1)
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
@@ -327,17 +283,6 @@ 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', () => {
@@ -349,7 +294,7 @@ describe('AudioPlayer', () => {
audio!.emit('play')
audio!.emit('ended')
audio!.emit('error')
audio!.emit('pause')
audio!.emit('paused')
audio!.emit('loaded')
audio!.emit('timeupdate')
audio!.emit('loadeddate')
@@ -409,7 +354,6 @@ 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 }]),
@@ -422,171 +366,25 @@ 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 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 () => {
it('should resume and play immediately when playAudio is called in suspended loaded state', 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()
await Promise.resolve()
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
resolveResume?.()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
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 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', async () => {
it('should play ended audio when data is already loaded', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
@@ -597,13 +395,11 @@ describe('AudioPlayer', () => {
audio!.ended = true
player.playAudio()
await waitFor(() => {
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
it('should report loaded audio that is already playing without replaying it', () => {
it('should only emit play callback without replaying when loaded audio is already playing', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
@@ -611,7 +407,6 @@ describe('AudioPlayer', () => {
player.isLoadData = true
audioContext!.state = 'running'
audio!.paused = false
audio!.ended = false
player.playAudio()
@@ -656,20 +451,22 @@ describe('AudioPlayer', () => {
})
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const mediaSource = testState.mediaSources[0]
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]
await player.playAudioWithAudio('', true)
await player.playAudioWithAudio('', true)
await vi.advanceTimersByTimeAsync(40)
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')
expect(player.isLoadData).toBe(false)
expect(player.cacheBuffers).toHaveLength(0)
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
} finally {
vi.useRealTimers()
}
})
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
@@ -682,8 +479,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)
@@ -691,11 +488,9 @@ describe('AudioPlayer', () => {
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
await waitFor(() => {
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
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 () => {
@@ -712,88 +507,6 @@ 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)
@@ -804,7 +517,7 @@ describe('AudioPlayer', () => {
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
expect(audio!.play).toHaveBeenCalledTimes(1)
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
expect(callback).toHaveBeenCalledWith('play')
})
it('should not replay when played list exists in playAudioWithAudio', async () => {
@@ -821,63 +534,18 @@ describe('AudioPlayer', () => {
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should report a play failure and retry without requesting audio again', async () => {
it('should replay when paused is false and played list is empty in playAudioWithAudio', 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'))
mediaSource!.emit('sourceopen')
player.playAudio()
audio!.paused = false
audio!.ended = false
audio!.played = null
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
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(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)
expect(callback).toHaveBeenCalledWith('play')
})
})
@@ -894,7 +562,7 @@ describe('AudioPlayer', () => {
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should finish stream when receiveAudioData gets empty bytes', () => {
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
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')
@@ -918,52 +586,6 @@ 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]
@@ -981,68 +603,19 @@ describe('AudioPlayer', () => {
expect(player.cacheBuffers.length).toBe(1)
})
it('should end the stream only after the final queued buffer is appended', () => {
it('should append cache chunks and end stream when finishStream drains buffers', () => {
vi.useFakeTimers()
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
player.cacheBuffers = [new ArrayBuffer(3)]
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.emit('updateend')
player.cacheBuffers = [new ArrayBuffer(3)]
;(player as unknown as { finishStream: () => void }).finishStream()
vi.advanceTimersByTime(50)
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
})
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')
vi.useRealTimers()
})
})
})

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