diff --git a/.github/workflows/marketplace-performance-e2e.yml b/.github/workflows/marketplace-performance-e2e.yml new file mode 100644 index 00000000000..94de24a534a --- /dev/null +++ b/.github/workflows/marketplace-performance-e2e.yml @@ -0,0 +1,70 @@ +name: Marketplace Performance E2E + +# Opt-in diagnostic: single-sample timing budgets are too noisy to gate every +# PR, so this lane is only run on demand instead of from the main CI pipeline. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Marketplace Performance E2E + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 60 + defaults: + run: + shell: bash + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup web dependencies + uses: ./.github/actions/setup-web + + - name: Setup UV and Python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + python-version: '3.12' + cache-dependency-glob: | + api/uv.lock + + - name: Install API dependencies + run: uv sync --project api --dev + + - name: Install Chromium for marketplace performance E2E + timeout-minutes: 15 + working-directory: ./e2e + run: vp run e2e:install:ci:chromium + + - name: Run marketplace performance benchmark + working-directory: ./e2e + env: + E2E_ADMIN_EMAIL: e2e-admin@example.com + E2E_ADMIN_NAME: E2E Admin + E2E_ADMIN_PASSWORD: E2eAdmin12345 + E2E_FORCE_WEB_BUILD: '1' + E2E_INIT_PASSWORD: E2eInit12345 + run: vp run e2e:marketplace-performance + + - name: Upload Cucumber report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cucumber-report-marketplace-performance + path: e2e/cucumber-report + retention-days: 7 + + - name: Upload E2E logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-logs-marketplace-performance + path: e2e/.logs/*.log + include-hidden-files: true + retention-days: 7 diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index c3953513449..20446914e6e 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -162,7 +162,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@dcb57747bfceeaa1fa72638cae52295d1d853d4a # v1.0.199 + uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/api/.importlinter b/api/.importlinter index f3609e5826a..7cf69c0c515 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -207,6 +207,7 @@ source_modules = services.account_avatar_service services.account_change_email_ports services.account_change_email_service + services.account_email_registration_service services.account_deletion_service services.account_deletion_feedback_service services.account_education_service diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index af1d9d6c971..358fd60c0fe 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -642,13 +642,13 @@ class AppListApi(Resource): ) permissions = enterprise_rbac_service.RBACService.MyPermissions.get( - str(current_tenant_id), + current_tenant_id, current_user_id, session=session, ) if dify_config.RBAC_ENABLED: access_filter = resolve_app_access_filter( - str(current_tenant_id), + current_tenant_id, current_user_id, session=session, permissions=permissions, @@ -675,7 +675,7 @@ class AppListApi(Resource): pagination_model = pagination_model.model_copy( update={ "data": [ - item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])}) + item.model_copy(update={"permission_keys": permission_keys_map.get(item.id, [])}) for item in pagination_model.data ] } @@ -712,7 +712,7 @@ class AppListApi(Resource): app_service = AppService() app = app_service.create_app(current_tenant_id, params, current_user, session=session) permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( - str(current_tenant_id), + current_tenant_id, current_user.id, [str(app.id)], session=session, @@ -882,7 +882,7 @@ class AppApi(Resource): app_model.access_mode = app_setting.access_mode permissions = enterprise_rbac_service.RBACService.MyPermissions.get( - str(current_tenant_id), + current_tenant_id, current_user.id, app_id=str(app_model.id), session=session, @@ -1020,7 +1020,7 @@ class AppCopyApi(Resource): raise NotFound("App not found") permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( - str(current_tenant_id), + current_tenant_id, current_user.id, [str(app.id)], session=session, @@ -1088,7 +1088,7 @@ class AppPublishToCreatorsPlatformApi(Resource): # TODO: Move this configuration and OAuth orchestration into the Creators Platform application service # when that domain is refactored. This controller-level integration is a temporary compatibility bridge. oauth_code = None - client_id = str(dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "") + client_id = dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "" if client_id: authorization = application_services().oauth_server.issue_authorization_code( client_id=client_id, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index cfa18235cd0..5f3982a65dc 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -16,6 +16,7 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model from controllers.console.app.error import ( + AgentSessionConfigurationChangedError, AppUnavailableError, CompletionRequestError, ConversationCompletedError, @@ -620,6 +621,10 @@ def _raise_agent_stream_error_before_response(response): if isinstance(response, _ClosableStream): response.close() message = error_payload.get("message") + if error_payload.get("code") == AgentSessionConfigurationChangedError.error_code: + raise AgentSessionConfigurationChangedError( + str(message or AgentSessionConfigurationChangedError.description) + ) raise CompletionRequestError(str(message or "Agent App chat failed.")) return _prepend_stream_chunks(buffered, chunk, iterator) diff --git a/api/controllers/console/app/error.py b/api/controllers/console/app/error.py index 1bb6fafb224..2a84336596e 100644 --- a/api/controllers/console/app/error.py +++ b/api/controllers/console/app/error.py @@ -1,3 +1,7 @@ +from core.app.apps.agent_app.errors import ( + AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE, + AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE, +) from libs.exception import BaseHTTPException @@ -49,6 +53,12 @@ class CompletionRequestError(BaseHTTPException): code = 400 +class AgentSessionConfigurationChangedError(BaseHTTPException): + error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE + description = AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE + code = 409 + + class AppMoreLikeThisDisabledError(BaseHTTPException): error_code = "app_more_like_this_disabled" description = "The 'More like this' feature is disabled. Please refresh your page." diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index 6fba79cfcbb..d29f8a1911b 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -412,6 +412,7 @@ class InstructionGenerateApi(Resource): model_config=req_data.model_config_data, ideal_output=req_data.ideal_output, workflow_service=WorkflowService(), + session=session, ) return {"error": "incompatible parameters"}, 400 except ProviderTokenNotInitError as ex: diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 31853a123a2..c7e51f663a4 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -353,7 +353,7 @@ class WorkflowResponse(ResponseModel): return [_serialize_environment_variable(item) for item in value] -class _WorkflowResponseSource: +class WorkflowResponseSource: def __init__(self, workflow: Workflow, *, session: Session) -> None: self._workflow = workflow self._session = session @@ -590,7 +590,8 @@ class DraftWorkflowApi(Resource): """ # fetch draft workflow by app_model workflow_service = WorkflowService() - workflow = workflow_service.get_draft_workflow(app_model=app_model, session=db.session()) + session = db.session() + workflow = workflow_service.get_draft_workflow(app_model=app_model, session=session) if not workflow: raise DraftWorkflowNotExist() @@ -599,9 +600,11 @@ class DraftWorkflowApi(Resource): # Return workflow with response-only Agent node job projection so the # front-end can treat draft graph node data as the editing source. - response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response = WorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=session), from_attributes=True + ).model_dump(mode="json") response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( - session=db.session(), + session=session, draft_workflow=workflow, ) return response @@ -1283,13 +1286,14 @@ class PublishedWorkflowApi(Resource): """ # fetch published workflow by app_model workflow_service = WorkflowService() - workflow = workflow_service.get_published_workflow(app_model=app_model, session=db.session()) + session = db.session() + workflow = workflow_service.get_published_workflow(app_model=app_model, session=session) # return workflow, if not found, return None if workflow is None: return None - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__]) @console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__]) @@ -1512,7 +1516,7 @@ class PublishedAllWorkflowApi(Resource): ) return WorkflowPaginationResponse.model_validate( { - "items": [_WorkflowResponseSource(workflow, session=session) for workflow in workflows], + "items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows], "page": page, "limit": limit, "has_more": has_more, @@ -1606,7 +1610,7 @@ class WorkflowByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - response = dump_response(WorkflowResponse, _WorkflowResponseSource(workflow, session=session)) + response = dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) return response diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index e81acbed99d..e0bfddccfa0 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -2,8 +2,6 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from configs import dify_config -from constants.languages import get_valid_language, languages from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns @@ -11,31 +9,35 @@ from controllers.console.auth.error import ( EmailAlreadyInUseError, EmailCodeError, EmailRegisterLimitError, + EmailRegisterRateLimitExceededError, InvalidEmailError, InvalidTokenError, NormalizedEmailAlreadyInUseError, PasswordMismatchError, ) -from enums import DeploymentEdition -from extensions.ext_database import db +from controllers.console.flask_admission import console_email_registration_admission +from controllers.console.wraps import model_validate +from extensions.ext_application_services import application_services from fields.base import ResponseModel -from libs.helper import EmailStr, extract_remote_ip +from libs.helper import EmailStr, dump_response, extract_remote_ip from libs.helper import timezone as validate_timezone_string from libs.password import valid_password -from models import Account -from services.account_service import AccountService -from services.billing_service import BillingService -from services.errors.account import ( +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, AccountNormalizedEmailAlreadyInUseError, - AccountRegisterError, - SeatsLimitExceededError, -) -from services.errors.account import ( - EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, + EmailRegistrationPasswordMismatchError, + EmailRegistrationSeatsLimitError, + EmailRegistrationSendIPLimitedError, + EmailRegistrationSendRateLimitError, + EmailRegistrationVerificationLimitError, + InvalidEmailRegistrationAddressError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, ) from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded -from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required class EmailRegisterSendPayload(BaseModel): @@ -91,146 +93,91 @@ register_response_schema_models( @console_ns.route("/email-register/send-email") class EmailRegisterSendEmailApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled @console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__]) + @console_email_registration_admission @model_validate(EmailRegisterSendPayload) - def post(self, req_data: EmailRegisterSendPayload): - normalized_email = req_data.email.lower() - - ip_address = extract_remote_ip(request) - if AccountService.is_email_send_ip_limit(ip_address): - raise EmailSendIpLimitError() - language = "en-US" - if req_data.language is not None and req_data.language in languages: - language = req_data.language - - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: - freeze_type = BillingService.get_email_freeze_type(normalized_email) - if freeze_type: - if freeze_type == "email_domain_suspended": - raise EmailDomainSuspendedError() - raise AccountInFreezeError() - - account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session()) - token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language) - return {"result": "success", "data": token} + def post(self, args: EmailRegisterSendPayload): + try: + token = application_services().accounts.email_registration.send_code( + remote_ip=extract_remote_ip(request), + requested_email=args.email, + requested_language=args.language, + ) + except EmailRegistrationSendIPLimitedError: + raise EmailSendIpLimitError() from None + except EmailRegistrationSendRateLimitError as error: + raise EmailRegisterRateLimitExceededError(error.retry_after_minutes) from None + except AccountEmailDomainSuspendedError: + raise EmailDomainSuspendedError() from None + except AccountEmailFrozenError: + raise AccountInFreezeError() from None + return dump_response(SimpleResultDataResponse, {"result": "success", "data": token}) @console_ns.route("/email-register/validity") class EmailRegisterCheckApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled @console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__]) + @console_email_registration_admission @model_validate(EmailRegisterValidityPayload) - def post(self, req_data: EmailRegisterValidityPayload): - - user_email = req_data.email.lower() - - is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(user_email) - if is_email_register_error_rate_limit: - raise EmailRegisterLimitError() - - token_data = AccountService.get_email_register_data(req_data.token) - if token_data is None: - raise InvalidTokenError() - - token_email = token_data.get("email") - normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email - - if user_email != normalized_token_email: - raise InvalidEmailError() - - if req_data.code != token_data.get("code"): - AccountService.add_email_register_error_rate_limit(user_email) - raise EmailCodeError() - - # Verified, revoke the first token - AccountService.revoke_email_register_token(req_data.token) - - # Refresh token data by generating a new token - _, new_token = AccountService.generate_email_register_token( - user_email, code=req_data.code, additional_data={"phase": "register"} + def post(self, args: EmailRegisterValidityPayload): + try: + verification = application_services().accounts.email_registration.verify_code( + email=args.email, + code=args.code, + token=args.token, + ) + except EmailRegistrationVerificationLimitError: + raise EmailRegisterLimitError() from None + except InvalidEmailRegistrationTokenError: + raise InvalidTokenError() from None + except InvalidEmailRegistrationAddressError: + raise InvalidEmailError() from None + except InvalidEmailRegistrationCodeError: + raise EmailCodeError() from None + return dump_response( + VerificationTokenResponse, + { + "is_valid": True, + "email": verification.email, + "token": verification.token, + }, ) - AccountService.reset_email_register_error_rate_limit(user_email) - return {"is_valid": True, "email": normalized_token_email, "token": new_token} - @console_ns.route("/email-register") class EmailRegisterResetApi(Resource): - @setup_required - @email_password_login_enabled - @email_register_enabled @console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__]) + @console_email_registration_admission @model_validate(EmailRegisterResetPayload) - def post(self, req_data: EmailRegisterResetPayload): - - # Validate passwords match - if req_data.new_password != req_data.password_confirm: - raise PasswordMismatchError() - - # Validate token and get register data - register_data = AccountService.get_email_register_data(req_data.token) - if not register_data: - raise InvalidTokenError() - # Must use token in reset phase - if register_data.get("phase", "") != "register": - raise InvalidTokenError() - - # Revoke token to prevent reuse - AccountService.revoke_email_register_token(req_data.token) - - email = register_data.get("email", "") - normalized_email = email.lower() - - account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session()) - - if account: - raise EmailAlreadyInUseError() - - ip_address = extract_remote_ip(request) - account = self._create_new_account( - email=normalized_email, - password=req_data.password_confirm, - timezone=req_data.timezone, - language=req_data.language, - ip_address=ip_address, - ) - token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address) - AccountService.reset_login_error_rate_limit(normalized_email) - - return {"result": "success", "data": token_pair.model_dump()} - - def _create_new_account( - self, - email: str, - password: str, - timezone: str | None = None, - language: str | None = None, - ip_address: str | None = None, - ) -> Account: + def post(self, args: EmailRegisterResetPayload): try: - return AccountService.create_account_and_tenant( - email=email, - name=email, - password=password, - interface_language=get_valid_language(language), - timezone=timezone, - ip_address=ip_address, - check_normalized_email=True, - session=db.session(), + token_pair = application_services().accounts.email_registration.register( + remote_ip=extract_remote_ip(request), + token=args.token, + new_password=args.new_password, + password_confirm=args.password_confirm, + language=args.language, + timezone=args.timezone, ) - except SeatsLimitExceededError: - raise SeatsLimitExceeded() - except EmailDomainSuspendedRegistrationError as exc: - raise EmailDomainSuspendedError() from exc - except AccountNormalizedEmailAlreadyInUseError as exc: - raise NormalizedEmailAlreadyInUseError() from exc - except AccountRegisterError as exc: - raise AccountInFreezeError() from exc + except EmailRegistrationPasswordMismatchError: + raise PasswordMismatchError() from None + except InvalidEmailRegistrationTokenError: + raise InvalidTokenError() from None + except AccountNormalizedEmailAlreadyInUseError: + raise NormalizedEmailAlreadyInUseError() from None + except AccountEmailAlreadyInUseError: + raise EmailAlreadyInUseError() from None + except EmailRegistrationSeatsLimitError: + raise SeatsLimitExceeded() from None + except AccountEmailDomainSuspendedError: + raise EmailDomainSuspendedError() from None + except AccountEmailFrozenError: + raise AccountInFreezeError() from None + + return dump_response( + EmailRegisterResetResponse, + {"result": "success", "data": token_pair}, + ) diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index daf7b344bee..af34860d5ec 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -55,7 +55,7 @@ class PasswordResetRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -65,7 +65,7 @@ class EmailRegisterRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -75,7 +75,7 @@ class EmailChangeRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -85,7 +85,7 @@ class OwnerTransferRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 1): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -137,7 +137,7 @@ class EmailCodeLoginRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 5): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) @@ -147,7 +147,7 @@ class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException): code = 429 def __init__(self, minutes: int = 5): - description = self.description.format(minutes=int(minutes)) if self.description else None + description = self.description.format(minutes=minutes) if self.description else None super().__init__(description=description) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 7c5e0cff893..432e83cc099 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -26,6 +26,7 @@ from controllers.console.app.workflow import ( DefaultBlockConfigsResponse, WorkflowPaginationResponse, WorkflowResponse, + WorkflowResponseSource, ) from controllers.console.app.wraps import with_session from controllers.console.datasets.wraps import get_rag_pipeline, load_rag_pipeline @@ -202,14 +203,15 @@ class DraftRagPipelineApi(Resource): Get draft rag pipeline's workflow """ # fetch draft workflow by app_model - rag_pipeline_service = RagPipelineService(db.session()) + session = db.session() + rag_pipeline_service = RagPipelineService(session) workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) if not workflow: raise DraftWorkflowNotExist() # return workflow, if not found, return 404 - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @setup_required @login_required @@ -548,14 +550,15 @@ class PublishedRagPipelineApi(Resource): if not pipeline.is_published: return None # fetch published workflow by pipeline - rag_pipeline_service = RagPipelineService(db.session()) + session = db.session() + rag_pipeline_service = RagPipelineService(session) workflow = rag_pipeline_service.get_published_workflow(pipeline=pipeline) # return workflow, if not found, return None if workflow is None: return None - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowPublishResponse.__name__]) @setup_required @@ -684,7 +687,7 @@ class PublishedAllRagPipelineApi(Resource): return WorkflowPaginationResponse.model_validate( { - "items": workflows, + "items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows], "page": page, "limit": limit, "has_more": has_more, @@ -763,7 +766,7 @@ class RagPipelineByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - return dump_response(WorkflowResponse, workflow) + return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session)) @console_ns.response(204, "Workflow deleted successfully") @setup_required diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index 5eafc9c4741..eb300128aed 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -22,6 +22,22 @@ from libs.login import current_account_with_tenant, login_required from machinery.context import RequestContext from machinery.errors import AdmissionConfigurationError from models.account import TenantAccountRole +from services.feature_service import FeatureService + + +def console_email_registration_admission[T, **P, R]( + view: Callable[Concatenate[T, P], R], +) -> Callable[Concatenate[T, P], R | Response]: + """Apply the complete admission policy for anonymous email registration.""" + + @wraps(view) + def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: + features = FeatureService.get_system_features() + if not features.enable_email_password_login or not features.is_allow_register: + abort(403) + return view(self, *args, **kwargs) + + return setup_required(check_registration_features) def console_account_admission[T, **P, R]( diff --git a/api/controllers/console/notification.py b/api/controllers/console/notification.py index 3e58f598bf7..080080bb361 100644 --- a/api/controllers/console/notification.py +++ b/api/controllers/console/notification.py @@ -1,56 +1,16 @@ -from collections.abc import Mapping -from typing import TypedDict - from flask_restx import Resource from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.wraps import ( - account_initialization_required, - model_validate, - only_edition_cloud, - setup_required, - with_current_user, -) +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import model_validate +from enums import DeploymentEdition +from extensions.ext_application_services import application_services from fields.base import ResponseModel -from libs.login import login_required -from models import Account -from services.billing_service import BillingService - -# Notification content is stored under three lang tags. -_FALLBACK_LANG = "en-US" - - -class NotificationLangContent(TypedDict, total=False): - lang: str - title: str - subtitle: str - body: str - titlePicUrl: str - - -class NotificationItemDict(TypedDict): - notification_id: str | None - frequency: str | None - lang: str - title: str - subtitle: str - body: str - title_pic_url: str - - -class NotificationResponseDict(TypedDict): - should_show: bool - notifications: list[NotificationItemDict] - - -def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent: - """Return the single LangContent for *lang*, falling back to English.""" - return ( - contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent()) - ) +from libs.helper import dump_response +from machinery.context import RequestContext class DismissNotificationPayload(BaseModel): @@ -92,39 +52,10 @@ class NotificationApi(Resource): }, ) @console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__]) - @setup_required - @login_required - @with_current_user - @account_initialization_required - @only_edition_cloud - def get(self, current_user: Account): - result = BillingService.get_account_notification(str(current_user.id)) - - # Proto JSON uses camelCase field names (Kratos default marshaling). - response: NotificationResponseDict - if not result.get("shouldShow"): - response = {"should_show": False, "notifications": []} - return response, 200 - - lang = current_user.interface_language or _FALLBACK_LANG - - notifications: list[NotificationItemDict] = [] - for notification in result.get("notifications") or []: - contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {} - lang_content = _pick_lang_content(contents, lang) - item: NotificationItemDict = { - "notification_id": notification.get("notificationId"), - "frequency": notification.get("frequency"), - "lang": lang_content.get("lang", lang), - "title": lang_content.get("title", ""), - "subtitle": lang_content.get("subtitle", ""), - "body": lang_content.get("body", ""), - "title_pic_url": lang_content.get("titlePicUrl", ""), - } - notifications.append(item) - - response = {"should_show": bool(notifications), "notifications": notifications} - return response, 200 + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext): + result = application_services().notifications.get_active(request_context) + return dump_response(NotificationResponse, result), 200 @console_ns.route("/notification/dismiss") @@ -134,17 +65,10 @@ class NotificationDismissApi(Resource): description="Mark a notification as dismissed for the current user.", responses={200: "Success", 401: "Unauthorized"}, ) - @setup_required - @login_required - @with_current_user - @account_initialization_required - @only_edition_cloud + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) @console_ns.expect(console_ns.models[DismissNotificationPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @model_validate(DismissNotificationPayload) - def post(self, payload: DismissNotificationPayload, current_user: Account): - BillingService.dismiss_notification( - notification_id=payload.notification_id, - account_id=str(current_user.id), - ) - return {"result": "success"}, 200 + def post(self, payload: DismissNotificationPayload, request_context: RequestContext): + application_services().notifications.dismiss(request_context, payload.notification_id) + return dump_response(SimpleResultResponse, {"result": "success"}), 200 diff --git a/api/controllers/console/onboarding.py b/api/controllers/console/onboarding.py index f26e2d539e4..cbd77752e7b 100644 --- a/api/controllers/console/onboarding.py +++ b/api/controllers/console/onboarding.py @@ -7,36 +7,20 @@ action-based so callers do not replace server-side arrays with stale snapshots. """ from datetime import datetime -from typing import Literal, cast from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, model_validator from controllers.common.schema import register_response_schema_models, register_schema_models -from extensions.ext_database import db +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import model_validate +from extensions.ext_application_services import application_services from fields.base import ResponseModel from libs.helper import dump_response -from libs.login import login_required -from models import Account -from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService +from machinery.context import RequestContext +from services.entities.onboarding_entities import StepByStepTourAction, StepByStepTourPatch, StepByStepTourTaskId from . import console_ns -from .wraps import ( - account_initialization_required, - model_validate, - setup_required, - with_current_tenant_id, - with_current_user, -) - -StepByStepTourAction = Literal[ - "skip", - "complete_task", - "uncomplete_task", - "enable_current_workspace", - "disable_current_workspace", -] -StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"] class StepByStepTourStatePatchPayload(BaseModel): @@ -74,39 +58,22 @@ class StepByStepTourStateApi(Resource): @console_ns.doc("get_step_by_step_tour_state") @console_ns.doc(description="Get account-level Step-by-step Tour state") @console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account): + @console_account_admission() + def get(self, request_context: RequestContext): return dump_response( StepByStepTourStateResponse, - StepByStepTourService.get_state( - account=current_user, - current_tenant_id=current_tenant_id, - session=db.session, - ), + application_services().step_by_step_tour.get_state(request_context), ) @console_ns.doc("patch_step_by_step_tour_state") @console_ns.doc(description="Update account-level Step-by-step Tour state") @console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id + @console_account_admission() @model_validate(StepByStepTourStatePatchPayload) - def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account): - patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True)) + def patch(self, req_data: StepByStepTourStatePatchPayload, request_context: RequestContext): + patch = StepByStepTourPatch(action=req_data.action, task_id=req_data.task_id) return dump_response( StepByStepTourStateResponse, - StepByStepTourService.patch_state( - account=current_user, - current_tenant_id=current_tenant_id, - patch=patch, - session=db.session, - ), + application_services().step_by_step_tour.patch_state(request_context, patch), ) diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 309a9d9ea1a..c293dc60e77 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -19,6 +19,7 @@ from controllers.console.app.workflow import ( WorkflowPaginationResponse, WorkflowPublishResponse, WorkflowResponse, + WorkflowResponseSource, WorkflowRestoreResponse, ) from controllers.console.snippets.payloads import ( @@ -179,9 +180,12 @@ class SnippetDraftWorkflowApi(Resource): raise DraftWorkflowNotExist() workflow.conversation_variables = [] - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + session = db.session() + response = SnippetWorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=session), from_attributes=True + ).model_dump(mode="json") response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( - session=db.session(), + session=session, draft_workflow=workflow, ) response["input_fields"] = snippet.input_fields_list @@ -274,7 +278,9 @@ class SnippetPublishedWorkflowApi(Resource): if not workflow: return None - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response = SnippetWorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=db.session()), from_attributes=True + ).model_dump(mode="json") response["input_fields"] = snippet.input_fields_list return response @@ -365,15 +371,15 @@ class SnippetPublishedAllWorkflowApi(Resource): limit=req_data.limit, ) - response = SnippetWorkflowPaginationResponse.model_validate( - { - "items": workflows, - "page": req_data.page, - "limit": req_data.limit, - "has_more": has_more, - }, - from_attributes=True, - ).model_dump(mode="json") + response = SnippetWorkflowPaginationResponse.model_validate( + { + "items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows], + "page": req_data.page, + "limit": req_data.limit, + "has_more": has_more, + }, + from_attributes=True, + ).model_dump(mode="json") for item in response["items"]: item["input_fields"] = snippet.input_fields_list return response @@ -464,9 +470,11 @@ class SnippetWorkflowByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") - response["input_fields"] = snippet.input_fields_list - return response + response = SnippetWorkflowResponse.model_validate( + WorkflowResponseSource(workflow, session=session), from_attributes=True + ).model_dump(mode="json") + response["input_fields"] = snippet.input_fields_list + return response @console_ns.doc("delete_snippet_workflow_by_id") @console_ns.doc(description="Delete a published snippet workflow version") diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 927b3b82899..ce3383ff13c 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -292,10 +292,8 @@ class AccountInitApi(Resource): @console_ns.expect(console_ns.models[AccountInitPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @console_account_admission(require_initialized=False) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountInitPayload.model_validate(payload) - + @model_validate(AccountInitPayload) + def post(self, args: AccountInitPayload, request_context: RequestContext): try: application_services().accounts.initialization.initialize( request_context, @@ -344,9 +342,8 @@ class AccountNameApi(Resource): @console_ns.expect(console_ns.models[AccountNamePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountNamePayload.model_validate(payload) + @model_validate(AccountNamePayload) + def post(self, args: AccountNamePayload, request_context: RequestContext): return _update_account_profile(request_context, AccountProfileChanges(name=args.name)) @@ -371,9 +368,8 @@ class AccountAvatarApi(Resource): @console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.") @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountAvatarPayload.model_validate(payload) + @model_validate(AccountAvatarPayload) + def post(self, args: AccountAvatarPayload, request_context: RequestContext): return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar)) @@ -387,9 +383,8 @@ class AccountInterfaceLanguageApi(Resource): @console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountInterfaceLanguagePayload.model_validate(payload) + @model_validate(AccountInterfaceLanguagePayload) + def post(self, args: AccountInterfaceLanguagePayload, request_context: RequestContext): return _update_account_profile( request_context, AccountProfileChanges(interface_language=args.interface_language), @@ -406,9 +401,8 @@ class AccountInterfaceThemeApi(Resource): @console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountInterfaceThemePayload.model_validate(payload) + @model_validate(AccountInterfaceThemePayload) + def post(self, args: AccountInterfaceThemePayload, request_context: RequestContext): return _update_account_profile( request_context, AccountProfileChanges(interface_theme=args.interface_theme), @@ -425,9 +419,8 @@ class AccountTimezoneApi(Resource): @console_ns.expect(console_ns.models[AccountTimezonePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountTimezonePayload.model_validate(payload) + @model_validate(AccountTimezonePayload) + def post(self, args: AccountTimezonePayload, request_context: RequestContext): return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone)) @@ -436,10 +429,8 @@ class AccountPasswordApi(Resource): @console_ns.expect(console_ns.models[AccountPasswordPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountPasswordPayload.model_validate(payload) - + @model_validate(AccountPasswordPayload) + def post(self, args: AccountPasswordPayload, request_context: RequestContext): try: assert args.password is not None account = application_services().accounts.password.change( @@ -498,10 +489,8 @@ class AccountDeleteApi(Resource): @console_ns.expect(console_ns.models[AccountDeletePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @console_account_admission() - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = AccountDeletePayload.model_validate(payload) - + @model_validate(AccountDeletePayload) + def post(self, args: AccountDeletePayload, request_context: RequestContext): try: application_services().accounts.deletion.request_deletion( request_context, @@ -519,10 +508,8 @@ class AccountDeleteUpdateFeedbackApi(Resource): @console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required - def post(self): - payload = console_ns.payload or {} - args = AccountDeletionFeedbackPayload.model_validate(payload) - + @model_validate(AccountDeletionFeedbackPayload) + def post(self, args: AccountDeletionFeedbackPayload): application_services().accounts.deletion_feedback.submit(email=args.email, feedback=args.feedback) return SimpleResultResponse(result="success").model_dump(mode="json") @@ -547,9 +534,8 @@ class EducationApi(Resource): @console_ns.expect(console_ns.models[EducationActivatePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__]) @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = EducationActivatePayload.model_validate(payload) + @model_validate(EducationActivatePayload) + def post(self, args: EducationActivatePayload, request_context: RequestContext): try: activation = application_services().accounts.education.activate( request_context, @@ -574,10 +560,8 @@ class EducationAutoCompleteApi(Resource): @console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery)) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__]) @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) - def get(self, request_context: RequestContext): - payload = request.args.to_dict(flat=True) - args = EducationAutocompleteQuery.model_validate(payload) - + @model_validate(EducationAutocompleteQuery) + def get(self, args: EducationAutocompleteQuery, request_context: RequestContext): return dump_response( EducationAutocompleteResponse, application_services().accounts.education.autocomplete( @@ -594,10 +578,8 @@ class ChangeEmailSendEmailApi(Resource): @console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__]) @console_account_admission(require_change_email_enabled=True) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = ChangeEmailSendPayload.model_validate(payload) - + @model_validate(ChangeEmailSendPayload) + def post(self, args: ChangeEmailSendPayload, request_context: RequestContext): ip_address = extract_remote_ip(request) language = "zh-Hans" if args.language == "zh-Hans" else "en-US" try: @@ -627,10 +609,8 @@ class ChangeEmailCheckApi(Resource): @console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__]) @console_account_admission(require_change_email_enabled=True) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = ChangeEmailValidityPayload.model_validate(payload) - + @model_validate(ChangeEmailValidityPayload) + def post(self, args: ChangeEmailValidityPayload, request_context: RequestContext): try: verification = application_services().accounts.change_email.verify_code( request_context, @@ -656,9 +636,8 @@ class ChangeEmailResetApi(Resource): @console_ns.expect(console_ns.models[ChangeEmailResetPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__]) @console_account_admission(require_change_email_enabled=True) - def post(self, request_context: RequestContext): - payload = console_ns.payload or {} - args = ChangeEmailResetPayload.model_validate(payload) + @model_validate(ChangeEmailResetPayload) + def post(self, args: ChangeEmailResetPayload, request_context: RequestContext): try: updated_account = application_services().accounts.change_email.reset( request_context, @@ -684,9 +663,8 @@ class CheckEmailUnique(Resource): @console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__]) @setup_required - def post(self): - payload = console_ns.payload or {} - args = CheckEmailUniquePayload.model_validate(payload) + @model_validate(CheckEmailUniquePayload) + def post(self, args: CheckEmailUniquePayload): try: application_services().accounts.change_email.ensure_available(args.email) except account_errors.AccountEmailDomainSuspendedError: diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index f5e2f85a0b7..ba1b3d816b3 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -221,7 +221,7 @@ class CustomizedSnippetDetailApi(Resource): """Update customized snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -265,7 +265,7 @@ class CustomizedSnippetDetailApi(Resource): """Delete customized snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -304,7 +304,7 @@ class CustomizedSnippetExportApi(Resource): """Export snippet as DSL.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -428,7 +428,7 @@ class CustomizedSnippetCheckDependenciesApi(Resource): """Check dependencies for a snippet.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) @@ -458,7 +458,7 @@ class CustomizedSnippetUseCountIncrementApi(Resource): """Increment snippet use count when it is inserted into a workflow.""" snippet_service = _snippet_service() snippet = snippet_service.get_snippet_by_id( - snippet_id=str(snippet_id), + snippet_id=snippet_id, tenant_id=current_tenant_id, ) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 7850f4d206a..da1d5584af6 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -352,19 +352,6 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R] return decorated -def email_register_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]: - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs): - features = FeatureService.get_system_features() - if features.is_allow_register: - return view(*args, **kwargs) - - # otherwise, return 403 - abort(403) - - return decorated - - def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): @@ -652,6 +639,23 @@ def with_current_user_id[T, **P, R]( return decorated +def validate_request[M: BaseModel](model: type[M]) -> M: + """Parse and validate the current request without exposing submitted values.""" + + if request.method == "GET": + raw = request.args.to_dict(flat=True) + elif request.method == "DELETE": + raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {}) + else: + raw = request.get_json(silent=True) or {} + + try: + return model.model_validate(raw) + except ValidationError as exc: + errors = exc.errors(include_url=False, include_input=False, include_context=False) + raise UnprocessableEntity(json.dumps(errors)) from None + + def model_validate[T, M: BaseModel, **P, R]( model: type[M], ) -> Callable[ @@ -671,19 +675,7 @@ def model_validate[T, M: BaseModel, **P, R]( ) -> Callable[Concatenate[T, P], R]: @wraps(view) def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R: - if request.method == "GET": - raw = request.args.to_dict(flat=True) - elif request.method == "DELETE": - raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {}) - else: - raw = request.get_json(silent=True) or {} - - try: - validated = model.model_validate(raw) - except ValidationError as exc: - raise UnprocessableEntity(exc.json()) - - return view(self, validated, *args, **kwargs) + return view(self, validate_request(model), *args, **kwargs) return wrapper diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py index 92884dfcd50..a53a379d948 100644 --- a/api/controllers/openapi/_errors.py +++ b/api/controllers/openapi/_errors.py @@ -67,6 +67,7 @@ class OpenApiErrorCode(StrEnum): MEMBER_LICENSE_EXCEEDED = "member_license_exceeded" HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found" RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch" + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable" class ErrorDetail(BaseModel): diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 772513ad417..631a750f0ee 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -35,6 +35,7 @@ from controllers.service_api.app.error import ( ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.apps.base_app_queue_manager import AppQueueManager @@ -57,6 +58,9 @@ from services.errors.app import ( WorkflowIdFormatError, WorkflowNotFoundError, ) +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) @@ -70,6 +74,8 @@ def _translate_service_errors() -> Generator[None, None, None]: raise NotFound(str(ex)) except (IsDraftWorkflowError, WorkflowIdFormatError) as ex: raise BadRequest(str(ex)) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except services.errors.conversation.ConversationCompletedError: diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 3441fafe034..2db10dcf42b 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -8,6 +8,7 @@ import services from controllers.common.controller_schemas import TextToAudioPayload from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse from controllers.common.schema import register_response_schema_models, register_schema_model +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import ( AppUnavailableError, @@ -181,14 +182,13 @@ class TextApi(Resource): # TTS returns provider audio bytes, so the success response is intentionally schema-less. @service_api_ns.response(200, "Text successfully converted to audio") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - def post(self, app_model: App, end_user: EndUser): + @model_validate(TextToAudioPayload) + def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser): """Convert text to audio using text-to-speech. Converts the provided text to audio using the specified voice. """ try: - payload = TextToAudioPayload.model_validate(service_api_ns.payload or {}) - message_id = payload.message_id text = payload.text voice = payload.voice diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index ac066cdf11d..163a50e959b 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import BadRequest, NotFound import services from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError from controllers.service_api.schema import expect_user_json, expect_with_user @@ -293,7 +294,8 @@ class ConversationRenameApi(Resource): service_api_ns.models[SimpleConversation.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - def post(self, app_model: App, end_user: EndUser, conversation_id: UUID): + @model_validate(ConversationRenamePayload) + def post(self, payload: ConversationRenamePayload, app_model: App, end_user: EndUser, conversation_id: UUID): """Rename a conversation or auto-generate a name.""" app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: @@ -301,8 +303,6 @@ class ConversationRenameApi(Resource): conversation_id_str = str(conversation_id) - payload = ConversationRenamePayload.model_validate(service_api_ns.payload or {}) - try: session = db.session() conversation = ConversationService.rename( @@ -408,7 +408,15 @@ class ConversationVariableDetailApi(Resource): service_api_ns.models[ConversationVariableResponse.__name__], ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - def put(self, app_model: App, end_user: EndUser, conversation_id: UUID, variable_id: UUID): + @model_validate(ConversationVariableUpdatePayload) + def put( + self, + payload: ConversationVariableUpdatePayload, + app_model: App, + end_user: EndUser, + conversation_id: UUID, + variable_id: UUID, + ): """Update a conversation variable's value. Allows updating the value of a specific conversation variable. @@ -421,8 +429,6 @@ class ConversationVariableDetailApi(Resource): conversation_id_str = str(conversation_id) variable_id_str = str(variable_id) - payload = ConversationVariableUpdatePayload.model_validate(service_api_ns.payload or {}) - try: variable = ConversationService.update_conversation_variable( app_model, conversation_id_str, variable_id_str, end_user, payload.value, session=db.session() diff --git a/api/controllers/service_api/app/error.py b/api/controllers/service_api/app/error.py index e6f97e98249..60959746d49 100644 --- a/api/controllers/service_api/app/error.py +++ b/api/controllers/service_api/app/error.py @@ -1,4 +1,8 @@ from libs.exception import BaseHTTPException +from services.errors.app import ( + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE, + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE, +) class AppUnavailableError(BaseHTTPException): @@ -37,6 +41,12 @@ class WorkflowVersionExecutionNotAllowedError(BaseHTTPException): code = 403 +class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException): + error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE + description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE + code = 403 + + class ConversationCompletedError(BaseHTTPException): error_code = "conversation_completed" description = "The conversation has ended. Please start a new conversation." diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 24f9fb7b62c..33a2f3a4b64 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -28,6 +28,7 @@ from controllers.service_api.app.error import ( ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, WorkflowVersionExecutionNotAllowedError, ) from controllers.service_api.schema import ( @@ -61,7 +62,14 @@ from models.model import App, AppMode, EndUser from repositories.factory import DifyAPIRepositoryFactory from services.app_generate_service import AppGenerateService from services.billing_service import BillingService -from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.app import ( + IsDraftWorkflowError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError from services.workflow_app_service import WorkflowAppService @@ -300,6 +308,11 @@ class WorkflowRunApi(Resource): "- `completion_request_error` : Workflow execution request failed.\n" "- `invalid_param` : Invalid parameter value." ), + 403: ( + "- `forbidden` : Token scope, app, or workspace access denied.\n" + "- `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through " + "Web App, Service API, OpenAPI, or MCP." + ), 429: ( "- `too_many_requests` : Too many concurrent requests for this app.\n" "- `rate_limit_error` : The upstream model provider rate limit was exceeded." @@ -360,6 +373,8 @@ class WorkflowRunApi(Resource): # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: @@ -406,8 +421,11 @@ class WorkflowRunByIdApi(Resource): "- `invalid_param` : Required parameter missing or invalid." ), 403: ( - "`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the " - "current plan. Upgrade to a paid plan." + "- `forbidden` : Token scope, app, or workspace access denied.\n" + "- `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the " + "current plan. Upgrade to a paid plan.\n" + "- `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry " + "and cannot be invoked through Web App, Service API, OpenAPI, or MCP." ), 404: "`not_found` : Workflow not found.", 429: ( @@ -487,6 +505,8 @@ class WorkflowRunByIdApi(Resource): # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except WorkflowNotFoundError as ex: raise NotFound(str(ex)) except IsDraftWorkflowError as ex: diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 51667e1fbde..da17036689f 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -25,7 +25,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.common.session import with_session -from controllers.console.wraps import edit_permission_required +from controllers.console.wraps import edit_permission_required, model_validate from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError from controllers.service_api.wraps import ( @@ -669,14 +669,13 @@ class DatasetApi(DatasetApiResource): ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session - def patch(self, session: Session, _, dataset_id: UUID): + @model_validate(DatasetUpdatePayload) + def patch(self, payload: DatasetUpdatePayload, session: Session, _, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str, session) if dataset is None: raise NotFound("Dataset not found.") - payload_dict = service_api_ns.payload or {} - payload = DatasetUpdatePayload.model_validate(payload_dict) update_data = payload.model_dump(exclude_unset=True) if payload.permission is not None: update_data["permission"] = str(payload.permission) @@ -944,13 +943,13 @@ class DatasetTagsApi(DatasetApiResource): service_api_ns.models[KnowledgeTagResponse.__name__], ) @with_session - def post(self, session: Session, _): + @model_validate(TagCreatePayload) + def post(self, payload: TagCreatePayload, session: Session, _): """Add a knowledge type tag.""" assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagCreatePayload.model_validate(service_api_ns.payload or {}) tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), session) response = KnowledgeTagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count="0") @@ -982,12 +981,12 @@ class DatasetTagsApi(DatasetApiResource): service_api_ns.models[KnowledgeTagResponse.__name__], ) @with_session - def patch(self, session: Session, _): + @model_validate(TagUpdatePayload) + def patch(self, payload: TagUpdatePayload, session: Session, _): assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagUpdatePayload.model_validate(service_api_ns.payload or {}) tag_id = payload.tag_id tag = TagService.update_tags( UpdateTagServicePayload(name=payload.name), tag_id, session, tag_type=TagType.KNOWLEDGE @@ -1019,9 +1018,9 @@ class DatasetTagsApi(DatasetApiResource): ) @edit_permission_required @with_session - def delete(self, session: Session, _): + @model_validate(TagDeletePayload) + def delete(self, payload: TagDeletePayload, session: Session, _): """Delete a knowledge type tag.""" - payload = TagDeletePayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag(payload.tag_id, session, tag_type=TagType.KNOWLEDGE) return "", 204 @@ -1049,13 +1048,13 @@ class DatasetTagBindingApi(DatasetApiResource): } ) @with_session - def post(self, session: Session, _): + @model_validate(TagBindingPayload) + def post(self, payload: TagBindingPayload, session: Session, _): # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagBindingPayload.model_validate(service_api_ns.payload or {}) TagService.save_tag_binding( TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE), session, @@ -1086,13 +1085,13 @@ class DatasetTagUnbindingApi(DatasetApiResource): } ) @with_session - def post(self, session: Session, _): + @model_validate(TagUnbindingPayload) + def post(self, payload: TagUnbindingPayload, session: Session, _): # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator assert isinstance(current_user, Account) if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag_binding( TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE), session, diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 216e3143339..5049c2a3782 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -45,6 +45,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.common.session import with_session +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.app.error import ProviderNotInitializeError from controllers.service_api.dataset.error import ( @@ -1069,9 +1070,8 @@ class DocumentBatchDownloadZipApi(DatasetApiResource): @service_api_ns.response(200, "ZIP archive generated successfully") @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session(write=False) - def post(self, session: Session, tenant_id, dataset_id: UUID): - payload = DocumentBatchDownloadZipPayload.model_validate(service_api_ns.payload or {}) - + @model_validate(DocumentBatchDownloadZipPayload) + def post(self, payload: DocumentBatchDownloadZipPayload, session: Session, tenant_id, dataset_id: UUID): upload_files, download_name = DocumentService.prepare_document_batch_download_zip( dataset_id=str(dataset_id), document_ids=[str(document_id) for document_id in payload.document_ids], diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py index 52fa8f370d5..693ba8377b9 100644 --- a/api/controllers/service_api/dataset/metadata.py +++ b/api/controllers/service_api/dataset/metadata.py @@ -316,15 +316,14 @@ class DocumentMetadataEditServiceApi(DatasetApiResource): ) @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @with_session - def post(self, session: Session, tenant_id, dataset_id: UUID): + @model_validate(MetadataOperationData) + def post(self, metadata_args: MetadataOperationData, session: Session, tenant_id, dataset_id: UUID): """Update metadata for multiple documents.""" dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), str(tenant_id), session=session) if dataset is None: raise NotFound("Dataset not found.") DatasetService.check_dataset_permission(dataset, current_user, session) - metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {}) - try: MetadataService.update_documents_metadata( dataset, metadata_args, cast(Account, current_user), session=session diff --git a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py index 35a083a3714..245466367b7 100644 --- a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py @@ -24,6 +24,7 @@ from controllers.common.schema import ( register_schema_model, ) from controllers.console.app.wraps import with_session +from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import PipelineRunError from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params @@ -215,7 +216,8 @@ class DatasourceNodeRunApi(DatasetApiResource): } ) @service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__]) - def post(self, tenant_id: str, dataset_id: UUID, node_id: str): + @model_validate(DatasourceNodeRunPayload) + def post(self, payload: DatasourceNodeRunPayload, tenant_id: str, dataset_id: UUID, node_id: str): """Resource for getting datasource plugins.""" dataset_id_str = str(dataset_id) # Verify dataset ownership @@ -224,7 +226,6 @@ class DatasourceNodeRunApi(DatasetApiResource): if not dataset: raise NotFound("Dataset not found.") - payload = DatasourceNodeRunPayload.model_validate(service_api_ns.payload or {}) assert isinstance(current_user, Account) rag_pipeline_service: RagPipelineService = RagPipelineService(db.session()) pipeline: Pipeline = rag_pipeline_service.get_pipeline(tenant_id=tenant_id, dataset_id=dataset_id_str) diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index a7b4fa38916..9a5dc0e6f4b 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -6,6 +6,7 @@ from werkzeug.exceptions import InternalServerError import services from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase +from controllers.console.wraps import model_validate from controllers.web import web_ns from controllers.web.error import ( AppUnavailableError, @@ -131,11 +132,10 @@ class TextApi(WebApiResource): ) # response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized. @web_ns.response(200, "Success") - def post(self, app_model: App, end_user: EndUser): + @model_validate(TextToAudioPayload) + def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser): """Convert text to audio""" try: - payload = TextToAudioPayload.model_validate(web_ns.payload or {}) - message_id = payload.message_id text = payload.text voice = payload.voice diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index 75aae01a576..abdf91aca56 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -8,6 +8,7 @@ from werkzeug.exceptions import NotFound from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate from controllers.web import web_ns from controllers.web.error import NotChatAppError from controllers.web.wraps import WebApiResource @@ -153,15 +154,14 @@ class ConversationRenameApi(WebApiResource): ) @web_ns.response(200, "Conversation renamed successfully", web_ns.models[SimpleConversation.__name__]) @web_ns.expect(web_ns.models[ConversationRenamePayload.__name__]) - def post(self, app_model: App, end_user: EndUser, c_id: UUID): + @model_validate(ConversationRenamePayload) + def post(self, payload: ConversationRenamePayload, app_model: App, end_user: EndUser, c_id: UUID): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}: raise NotChatAppError() conversation_id = str(c_id) - payload = ConversationRenamePayload.model_validate(web_ns.payload or {}) - try: session = db.session() conversation = ConversationService.rename( diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py index b0ab2f0334c..16253f06eee 100644 --- a/api/controllers/web/error.py +++ b/api/controllers/web/error.py @@ -1,4 +1,8 @@ from libs.exception import BaseHTTPException +from services.errors.app import ( + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE, + TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE, +) class AppUnavailableError(BaseHTTPException): @@ -31,6 +35,12 @@ class NotWorkflowAppError(BaseHTTPException): code = 400 +class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException): + error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE + description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE + code = 403 + + class ConversationCompletedError(BaseHTTPException): error_code = "conversation_completed" description = "The conversation has ended. Please start a new conversation." diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index b12661dc5cf..ab099077b79 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -9,6 +9,7 @@ from controllers.common.errors import ( RemoteFileUploadError, UnsupportedFileTypeError, ) +from controllers.console.wraps import model_validate from core.file import remote_fetcher from extensions.ext_database import db from fields.file_fields import FileWithSignedUrl, RemoteFileInfo @@ -86,7 +87,8 @@ class RemoteFileUploadApi(WebApiResource): ) @web_ns.response(201, "Remote file uploaded", web_ns.models[FileWithSignedUrl.__name__]) @web_ns.expect(web_ns.models[RemoteFileUploadPayload.__name__]) - def post(self, app_model: App, end_user: EndUser): + @model_validate(RemoteFileUploadPayload) + def post(self, payload: RemoteFileUploadPayload, app_model: App, end_user: EndUser): """Upload a file from a remote URL. Downloads a file from the provided remote URL and uploads it @@ -108,7 +110,6 @@ class RemoteFileUploadApi(WebApiResource): FileTooLargeError: File exceeds size limit UnsupportedFileTypeError: File type not supported """ - payload = RemoteFileUploadPayload.model_validate(web_ns.payload or {}) url = str(payload.url) try: diff --git a/api/controllers/web/workflow.py b/api/controllers/web/workflow.py index 1e6d6e24d92..9dc728e2efd 100644 --- a/api/controllers/web/workflow.py +++ b/api/controllers/web/workflow.py @@ -14,6 +14,7 @@ from controllers.web.error import ( ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from controllers.web.wraps import WebApiResource @@ -30,6 +31,9 @@ from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) @@ -78,6 +82,8 @@ class WorkflowRunApi(WebApiResource): # response-contract:ignore compact_generate_response return helper.compact_generate_response(response) + except TriggerWorkflowServiceModeUnavailableServiceError: + raise TriggerWorkflowServiceModeUnavailableError() except ProviderTokenNotInitError as ex: raise ProviderNotInitializeError(ex.description) except QuotaExceededError: diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index b2457a44a04..5381cd4b1c3 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -31,7 +31,11 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager from core.app.apps.agent_app.app_runner import AgentAppRunner -from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError +from core.app.apps.agent_app.errors import ( + AgentAppGeneratorError, + AgentAppNotPublishedError, + AgentSessionSnapshotIncompatibleError, +) from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore @@ -531,6 +535,15 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) except GenerateTaskStoppedError: pass + except AgentSessionSnapshotIncompatibleError as error: + logger.info( + "Agent App session snapshot no longer matches the current composition", + extra={ + "agent_id": application_generate_entity.agent_id, + "conversation_id": conversation_id, + }, + ) + queue_manager.publish_error(error, PublishFrom.APPLICATION_MANAGER) except Exception as e: logger.exception("Unknown Error in Agent App generate worker") queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) diff --git a/api/core/app/apps/agent_app/errors.py b/api/core/app/apps/agent_app/errors.py index 51b4e77116a..bdcd38abfdf 100644 --- a/api/core/app/apps/agent_app/errors.py +++ b/api/core/app/apps/agent_app/errors.py @@ -1,6 +1,24 @@ +from core.app.apps.exc import AppGenerateError + +AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE = "agent_session_configuration_changed" +AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE = ( + "The Agent configuration changed after this conversation started. Start a new conversation to continue." +) + + class AgentAppGeneratorError(ValueError): """Raised when an Agent App turn cannot be set up.""" class AgentAppNotPublishedError(AgentAppGeneratorError): """Raised when a public Agent App runtime is requested before publish.""" + + +class AgentSessionSnapshotIncompatibleError(AppGenerateError): + """Raised when a retained session snapshot no longer matches the current composition.""" + + error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE + status_code = 409 + + def __init__(self) -> None: + super().__init__(AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE) diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 23b93d2f9bb..70c813d0fd5 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -50,6 +50,8 @@ from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig from models.provider_ids import ModelProviderID from services.agent.prompt_mentions import expand_prompt_mentions +from .errors import AgentSessionSnapshotIncompatibleError + class AgentAppRuntimeRequestBuildError(ValueError): """Raised when Agent App state cannot be mapped to a valid run request.""" @@ -191,6 +193,7 @@ class AgentAppRuntimeRequestBuilder: metadata=metadata, ) ) + self._validate_session_snapshot_layers(request) redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) return AgentAppRuntimeRequest( request=request, @@ -199,6 +202,24 @@ class AgentAppRuntimeRequestBuilder: binding_id=context.binding_id, ) + @staticmethod + def _validate_session_snapshot_layers(request: CreateRunRequest) -> None: + """Reject stale snapshots before they reach the Agent backend. + + Draft rows are updated in place, so their IDs cannot prove that a + retained snapshot still belongs to the current composition. Agenton + requires the ordered layer names to match exactly; enforce the same + invariant at the API boundary and return a product-level error. + """ + + snapshot = request.session_snapshot + if snapshot is None: + return + snapshot_layer_names = tuple(layer.name for layer in snapshot.layers) + composition_layer_names = tuple(layer.name for layer in request.composition.layers) + if snapshot_layer_names != composition_layer_names: + raise AgentSessionSnapshotIncompatibleError() + def _build_tool_layers( self, *, diff --git a/api/core/app/apps/base_app_generate_response_converter.py b/api/core/app/apps/base_app_generate_response_converter.py index aef54cc049e..0576bd48318 100644 --- a/api/core/app/apps/base_app_generate_response_converter.py +++ b/api/core/app/apps/base_app_generate_response_converter.py @@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType from pydantic import JsonValue from clients.agent_backend.errors import AgentBackendError, AgentBackendRunFailedError +from core.app.apps.exc import AppGenerateError from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError @@ -125,6 +126,13 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC): "message": str(e), } + if isinstance(e, AppGenerateError): + return { + "code": e.error_code, + "status": e.status_code, + "message": str(e), + } + error_responses: dict[type[Exception], dict[str, JsonValue]] = { ValueError: {"code": "invalid_param", "status": 400}, ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400}, diff --git a/api/core/app/apps/exc.py b/api/core/app/apps/exc.py index 4187118b9bc..e5cb5d31b9a 100644 --- a/api/core/app/apps/exc.py +++ b/api/core/app/apps/exc.py @@ -1,2 +1,9 @@ +class AppGenerateError(ValueError): + """Base class for application-generation errors with a stable response contract.""" + + error_code: str + status_code: int + + class GenerateTaskStoppedError(Exception): pass diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index c6842b2b86c..03fc06f0d3f 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -841,9 +841,8 @@ class LLMGenerator: model_config: ModelConfig, ideal_output: str | None, workflow_service: WorkflowServiceInterface, + session: Session, ): - session = db.session() - app: App | None = session.scalar(select(App).where(App.id == flow_id, App.tenant_id == tenant_id).limit(1)) if not app: raise ValueError("App not found.") diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py index 7fd03788c7e..3b96c5da43e 100644 --- a/api/core/mcp/server/streamable_http.py +++ b/api/core/mcp/server/streamable_http.py @@ -12,6 +12,7 @@ from core.mcp import types as mcp_types from graphon.variables.input_entities import VariableEntity, VariableEntityType from models.model import App, AppMCPServer, AppMode, EndUser from services.app_generate_service import AppGenerateService +from services.errors.app import TriggerWorkflowServiceModeUnavailableError logger = logging.getLogger(__name__) @@ -93,11 +94,16 @@ def handle_mcp_request( result=result_data.model_dump(by_alias=True, mode="json", exclude_none=True), ) - def create_error_response(code: int, message: str) -> mcp_types.JSONRPCError: + def create_error_response( + code: int, + message: str, + *, + data: Mapping[str, Any] | None = None, + ) -> mcp_types.JSONRPCError: """Create error response with error code and message""" from core.mcp.types import ErrorData - error_data = ErrorData(code=code, message=message) + error_data = ErrorData(code=code, message=message, data=data) return mcp_types.JSONRPCError( jsonrpc="2.0", id=request_id, @@ -131,6 +137,12 @@ def handle_mcp_request( case _: return create_error_response(mcp_types.METHOD_NOT_FOUND, f"Method not found: {request_type.__name__}") + except TriggerWorkflowServiceModeUnavailableError as e: + return create_error_response( + mcp_types.INVALID_REQUEST, + str(e), + data={"code": e.error_code}, + ) except ValueError as e: logger.exception("Invalid params") return create_error_response(mcp_types.INVALID_PARAMS, str(e)) diff --git a/api/core/workflow/generator/runner.py b/api/core/workflow/generator/runner.py index a0fa6e3f500..42095fbf85c 100644 --- a/api/core/workflow/generator/runner.py +++ b/api/core/workflow/generator/runner.py @@ -1193,7 +1193,7 @@ class WorkflowGenerator: if node.get("node_type") == BuiltinNodeTypes.TOOL and node.get("id") } for node in graph.get("nodes") or []: - planned = planned_by_id.get(str(node.get("id") or "")) + planned = planned_by_id.get(node.get("id") or "") if planned is None: continue data = node.get("data") diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 991a487c107..a9451c52778 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -76,6 +76,10 @@ def _schema_markdown_type(schema: object) -> str: item_type = _schema_markdown_type(schema.get("items")) return f"[ {item_type or 'object'} ]" if isinstance(schema_type, str): + enum_values = schema.get("enum") + if isinstance(enum_values, list) and enum_values: + rendered_values = ", ".join(json.dumps(value, ensure_ascii=False) for value in enum_values) + return f"{schema_type},
**Available values:** {rendered_values}" return schema_type return "" diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index e29b92c8617..747c658a561 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -31,6 +31,7 @@ from repositories.factory import DifyAPIRepositoryFactory from repositories.installation_state_repository import InstallationStateRepository from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository +from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository from repositories.tag_repository import TagRepository from repositories.trial_app_query_repository import TrialAppQueryRepository from repositories.trial_app_usage_repository import TrialAppUsageRepository @@ -70,6 +71,16 @@ from services.account_deletion_adapters import ( from services.account_deletion_feedback_service import AccountDeletionFeedbackService from services.account_deletion_service import AccountDeletionService from services.account_education_service import AccountEducationService +from services.account_email_registration_adapters import ( + AccountServiceRegistrationGateway, + BillingAccountRegistrationPolicyGateway, + CeleryEmailRegistrationNotificationGateway, + RateLimiterEmailRegistrationSendLimiter, + RedisEmailRegistrationSecurityGateway, + SecureEmailRegistrationCodeGenerator, + TokenManagerEmailRegistrationTokenGateway, +) +from services.account_email_registration_service import AccountEmailRegistrationService from services.account_initialization_service import AccountInitializationService from services.account_integration_service import AccountIntegrationService from services.account_password_hasher import LegacyAccountPasswordHasher @@ -94,6 +105,8 @@ from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway from services.file_service import FileService from services.init_validation_service import InitValidationService +from services.notification_gateway import BillingNotificationGateway +from services.notification_service import NotificationService from services.notion_data_source_gateway import NotionDataSourceGateway from services.oauth_server_service import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthServerService from services.partner_tenant_binding_service import PartnerTenantBindingService @@ -112,6 +125,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi from services.schema_definition_service import SchemaDefinitionService from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner from services.setup_service import SetupService +from services.step_by_step_tour_service import StepByStepTourService from services.tag_application_service import TagApplicationService from services.trial_app_usage import TrialAppUsageRecorder from services.web_app_runtime_query_service import WebAppRuntimeQueryService @@ -150,6 +164,7 @@ def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool: class AccountServices: avatar: AccountAvatarService change_email: AccountChangeEmailService + email_registration: AccountEmailRegistrationService deletion: AccountDeletionService deletion_feedback: AccountDeletionFeedbackService education: AccountEducationService @@ -177,6 +192,8 @@ class ApplicationServices: feature_queries: FeatureQueryService oauth_server: OAuthServerService init_validation: InitValidationService + notifications: NotificationService + step_by_step_tour: StepByStepTourService partner_tenant_bindings: PartnerTenantBindingService recommended_app_queries: RecommendedAppQueryService trial_app_usage: TrialAppUsageRecorder @@ -278,6 +295,29 @@ def build_application_services( billing_enabled=deployment_edition == DeploymentEdition.CLOUD, ), ), + email_registration=AccountEmailRegistrationService( + accounts=accounts, + tokens=TokenManagerEmailRegistrationTokenGateway(), + codes=SecureEmailRegistrationCodeGenerator(), + notifications=CeleryEmailRegistrationNotificationGateway(), + send_limits=RateLimiterEmailRegistrationSendLimiter( + rate_limiter=RateLimiter( + prefix="email_register_rate_limit", + max_attempts=1, + time_window=60, + redis_client=redis, + ) + ), + security=RedisEmailRegistrationSecurityGateway( + redis=redis, + verification_failure_limit=5, + verification_lockout_duration=dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, + ), + account_policy=BillingAccountRegistrationPolicyGateway( + enabled=deployment_edition == DeploymentEdition.CLOUD, + ), + registration=AccountServiceRegistrationGateway(session_factory=database_client), + ), deletion=AccountDeletionService( accounts=accounts, memberships=workspace_query_repository, @@ -400,6 +440,16 @@ def build_application_services( validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)), expected_password=initialization_password, ), + notifications=NotificationService( + accounts=accounts, + notifications=BillingNotificationGateway(), + ), + step_by_step_tour=StepByStepTourService( + accounts=accounts, + states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client), + enabled=dify_config.ENABLE_STEP_BY_STEP_TOUR, + rollout_started_at=dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT, + ), partner_tenant_bindings=PartnerTenantBindingService( sync_bindings=BillingService.sync_partner_tenants_bindings, ), diff --git a/api/models/workflow.py b/api/models/workflow.py index 954f1787c28..95159da2bd8 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -297,18 +297,16 @@ class Workflow(Base): # bug workflow.updated_at = workflow.created_at return workflow - @property - def created_by_account(self) -> Account | None: - return self.get_created_by_account(session=db.session()) + def created_by_account(self, session: orm.Session) -> Account | None: + return self.get_created_by_account(session=session) - def get_created_by_account(self, *, session: orm.Session) -> Account | None: + def get_created_by_account(self, session: orm.Session) -> Account | None: return session.get(Account, self.created_by) - @property - def updated_by_account(self) -> Account | None: - return self.get_updated_by_account(session=db.session()) + def updated_by_account(self, session: orm.Session) -> Account | None: + return self.get_updated_by_account(session=session) - def get_updated_by_account(self, *, session: orm.Session) -> Account | None: + def get_updated_by_account(self, session: orm.Session) -> Account | None: return session.get(Account, self.updated_by) if self.updated_by else None @property @@ -564,18 +562,17 @@ class Workflow(Base): # bug return helper.generate_text_hash(json.dumps(entity, sort_keys=True)) - @property @deprecated( - "This property is not accurate for determining if a workflow is published as a tool." + "This method is not accurate for determining if a workflow is published as a tool." "It only checks if there's a WorkflowToolProvider for the app, " "not if this specific workflow version is the one being used by the tool." ) - def tool_published(self) -> bool: - return self.get_tool_published(session=db.session()) + def tool_published(self, session: orm.Session) -> bool: + return self.get_tool_published(session=session) - def get_tool_published(self, *, session: orm.Session) -> bool: + def get_tool_published(self, session: orm.Session) -> bool: """ - DEPRECATED: This property is not accurate for determining if a workflow is published as a tool. + DEPRECATED: This method is not accurate for determining if a workflow is published as a tool. It only checks if there's a WorkflowToolProvider for the app, not if this specific workflow version is the one being used by the tool. diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b8008497e43..c143fd19243 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -13501,7 +13501,7 @@ default (the config form sends the full desired feature state on save). | mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | | name | string | Filter by app name | No | | page | integer,
**Default:** 1 | Page number (1-99999) | No | -| publication_status | string | Filter by published or draft Agent configuration status | No | +| publication_status | string,
**Available values:** "drafts", "published" | Filter by published or draft Agent configuration status | No | | sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | | tag_ids | [ string ] | Filter by tag IDs | No | @@ -15744,7 +15744,7 @@ AppMCPServer Status Enum | copyright | string | | No | | custom_disclaimer | string | | No | | customize_domain | string | | No | -| customize_token_strategy | string | | No | +| customize_token_strategy | string,
**Available values:** "allow", "must", "not_allow" | | No | | default_language | string | | No | | description | string | | No | | icon | string | | No | @@ -16202,7 +16202,7 @@ TEAM: Team collaboration paid plan | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | | No | | retriever_from | string,
**Default:** explore_app | | No | #### CompletionMessagePayload @@ -16223,7 +16223,7 @@ TEAM: Team collaboration paid plan | files | [ object ] | | No | | inputs | object | | Yes | | query | string | | No | -| response_mode | string | | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | | No | | retriever_from | string,
**Default:** explore_app | | No | #### ComplianceDownloadQuery @@ -18263,9 +18263,9 @@ Flask blueprint initialization. | ---- | ---- | ----------- | -------- | | end_date | string | End date (YYYY-MM-DD) | No | | format | string,
**Available values:** "csv", "json",
**Default:** csv | Export format
*Enum:* `"csv"`, `"json"` | No | -| from_source | string | Filter by feedback source | No | +| from_source | string,
**Available values:** "admin", "user" | Filter by feedback source | No | | has_comment | boolean | Only include feedback with comments | No | -| rating | string | Filter by rating | No | +| rating | string,
**Available values:** "dislike", "like" | Filter by rating | No | | start_date | string | Start date (YYYY-MM-DD) | No | #### FeedbackStat @@ -18663,7 +18663,7 @@ Icon information model. | ---- | ---- | ----------- | -------- | | icon | string | | No | | icon_background | string | | No | -| icon_type | string | | No | +| icon_type | string,
**Available values:** "emoji", "image" | | No | | icon_url | string | | No | #### IconType @@ -19245,7 +19245,7 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | | message_id | string | Message ID | Yes | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFile @@ -19306,7 +19306,7 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No | -| logical_operator | string | How to combine multiple conditions. | No | +| logical_operator | string,
**Available values:** "and", "or" | How to combine multiple conditions. | No | #### MetadataOperationData @@ -19439,7 +19439,7 @@ Enum class for model property key. | is_exhausted | boolean | | Yes | | is_unlimited | boolean | | Yes | | next_credit_reset_date | integer | | Yes | -| pool_type | string | | Yes | +| pool_type | string,
**Available values:** "paid", "trial" | | Yes | | quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes | | quota_used | integer | | Yes | | remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes | @@ -21434,7 +21434,7 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No | | reranking_enable | boolean | Whether reranking is enabled. | Yes | -| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No | +| reranking_mode | string,
**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No | | reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No | | score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No | | score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes | @@ -21488,7 +21488,7 @@ Model class for provider quota configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | Parent-child segmentation mode. | No | +| parent_mode | string,
**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No | | pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No | | segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No | | subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No | @@ -22477,7 +22477,7 @@ Query parameters for listing snippet published workflows. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string,
**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action
*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes | -| task_id | string | Task ID for task actions | No | +| task_id | string,
**Available values:** "home", "integration", "knowledge", "studio" | Task ID for task actions | No | #### StepByStepTourStateResponse @@ -22943,7 +22943,7 @@ Tool label | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No | +| visibility | string,
**Available values:** "all_team_members", "only_me" | Visibility for the OAuth credential. Defaults to 'only_me'. | No | #### ToolOAuthCustomClientPayload @@ -23075,7 +23075,7 @@ removes TOOLS_SELECTOR from PluginParameterType | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| type | string | | No | +| type | string,
**Available values:** "api", "builtin", "mcp", "model", "workflow" | | No | #### ToolProviderListResponse @@ -23693,7 +23693,7 @@ in form definition, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No | | vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No | -| weight_type | string | Strategy for balancing semantic and keyword search weights. | No | +| weight_type | string,
**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No | #### WeightVectorSetting @@ -24199,7 +24199,7 @@ can reuse its existing handler. | description | string | | No | | event | string | | No | | icon | string | | No | -| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes | +| mode | string,
**Available values:** "advanced-chat", "workflow" | *Enum:* `"advanced-chat"`, `"workflow"` | Yes | | nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes | | start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No | | title | string | | No | @@ -24214,7 +24214,7 @@ can reuse its existing handler. | graph | [WorkflowGraph](#workflowgraph) | | Yes | | icon | string | | No | | message | string | | No | -| mode | string | | No | +| mode | string,
**Available values:** "advanced-chat", "workflow" | | No | #### WorkflowGenerateResultEventResponse @@ -24227,7 +24227,7 @@ can reuse its existing handler. | graph | [WorkflowGraph](#workflowgraph) | | Yes | | icon | string | | No | | message | string | | No | -| mode | string | | No | +| mode | string,
**Available values:** "advanced-chat", "workflow" | | No | #### WorkflowGenerateStreamEventResponse @@ -24527,9 +24527,9 @@ Lifecycle state for an asynchronous archive download request. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| status | string | Workflow run status filter | No | +| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No | | time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | -| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No | +| triggered_from | string,
**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No | #### WorkflowRunCountResponse @@ -24601,8 +24601,8 @@ Lifecycle state for an asynchronous archive download request. | ---- | ---- | ----------- | -------- | | last_id | string | Last run ID for pagination | No | | limit | integer,
**Default:** 20 | Number of items per page (1-100) | No | -| status | string | Workflow run status filter | No | -| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No | +| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No | +| triggered_from | string,
**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No | #### WorkflowRunNodeExecutionListResponse @@ -24900,7 +24900,7 @@ Workflow tool configuration | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | string | Localized policy label language | No | +| language | string,
**Available values:** "en", "ja", "zh" | Localized policy label language | No | #### _AccessPolicyList @@ -24959,7 +24959,7 @@ Workflow tool configuration | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| language | string | Localized policy label language | No | +| language | string,
**Available values:** "en", "ja", "zh" | Localized policy label language | No | | limit | integer | | No | | page | integer | | No | | reverse | boolean | | No | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index a09bd57e255..92a9f9a91de 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2211,7 +2211,7 @@ Execute a workflow. Cannot be executed without a published workflow. | 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)
**text/event-stream**: string
| | 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | | | 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through Web App, Service API, OpenAPI, or MCP. | | | 404 | Workflow not found | | | 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | | 500 | `internal_server_error` : Internal server error. | | @@ -2287,7 +2287,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p | 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)
**text/event-stream**: string
| | 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | | | 401 | Unauthorized - invalid API token | | -| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | | +| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. - `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP. | | | 404 | `not_found` : Workflow not found. | | | 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | | 500 | `internal_server_error` : Internal server error. | | @@ -2587,7 +2587,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or question content. | Yes | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | | workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No | #### ChatRequestPayloadWithUser @@ -2599,7 +2599,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or question content. | Yes | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | | workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No | @@ -2672,7 +2672,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or prompt content. | No | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | #### CompletionRequestPayloadWithUser @@ -2681,7 +2681,7 @@ Public pause reason emitted by a blocking Chatflow execution. | files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | | query | string | User input or prompt content. | No | -| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### Condition @@ -2797,7 +2797,7 @@ Enum class for custom configuration status. | embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | | external_knowledge_api_id | string | ID of the external knowledge API. | No | | external_knowledge_id | string | ID of the external knowledge base. | No | -| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | | name | string | Name of the knowledge base. | Yes | | permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No | | provider | string,
**Available values:** "external", "vendor",
**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.
*Enum:* `"external"`, `"vendor"` | No | @@ -3039,7 +3039,7 @@ Enum class for custom configuration status. | external_knowledge_api_id | string | ID of the external knowledge API. | No | | external_knowledge_id | string | ID of the external knowledge base. | No | | external_retrieval_model | object | Retrieval settings for external knowledge bases. | No | -| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | | name | string | Name of the knowledge base. | No | | partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No | | permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No | @@ -3167,7 +3167,7 @@ Request payload for bulk downloading documents as a zip archive. | keyword | string | Search keyword to filter by document name. | No | | limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No | | page | integer,
**Default:** 1 | Page number to retrieve. | No | -| status | string | Filter by display status. | No | +| status | string,
**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | Filter by display status. | No | #### DocumentListResponse @@ -3265,7 +3265,7 @@ Request payload for bulk downloading documents as a zip archive. | doc_language | string,
**Default:** English | Language of the document for processing optimization. | No | | embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | | embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | -| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No | | name | string | Document name. | Yes | | original_document_id | string | Original document ID for replacement. | No | | process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No | @@ -3614,14 +3614,14 @@ Model class for i18n object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFeedbackPayloadWithUser | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### MessageFile @@ -3701,7 +3701,7 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No | -| logical_operator | string | How to combine multiple conditions. | No | +| logical_operator | string,
**Available values:** "and", "or" | How to combine multiple conditions. | No | #### MetadataOperationData @@ -3935,7 +3935,7 @@ Model class for provider with models response. | ---- | ---- | ----------- | -------- | | metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No | | reranking_enable | boolean | Whether reranking is enabled. | Yes | -| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No | +| reranking_mode | string,
**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No | | reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No | | score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No | | score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes | @@ -3969,7 +3969,7 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | Parent-child segmentation mode. | No | +| parent_mode | string,
**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No | | pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No | | segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No | | subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No | @@ -4300,7 +4300,7 @@ in form definition, or a variable while the workflow is running. | ---- | ---- | ----------- | -------- | | keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No | | vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No | -| weight_type | string | Strategy for balancing semantic and keyword search weights. | No | +| weight_type | string,
**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No | #### WeightVectorSetting @@ -4383,7 +4383,7 @@ Blocking workflow response for a finished or paused execution. | keyword | string | Keyword to search in logs. | No | | limit | integer,
**Default:** 20 | Number of items per page. | No | | page | integer,
**Default:** 1 | Page number for pagination. | No | -| status | string | Filter by execution status. | No | +| status | string,
**Available values:** "failed", "stopped", "succeeded" | Filter by execution status. | No | #### WorkflowPauseReasonResponse @@ -4452,7 +4452,7 @@ Public pause reason emitted by a blocking Workflow execution. | ---- | ---- | ----------- | -------- | | files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | -| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | #### WorkflowRunPayloadWithUser @@ -4460,7 +4460,7 @@ Public pause reason emitted by a blocking Workflow execution. | ---- | ---- | ----------- | -------- | | files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | | inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | -| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | | user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### WorkflowRunResponse diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index e534fe39350..3cc99e099ce 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1019,7 +1019,7 @@ Button styles for user actions. | inputs | object | Input variables for the chat | Yes | | parent_message_id | string | Parent message ID | No | | query | string | User query/message | Yes | -| response_mode | string | Response mode: blocking or streaming | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No | | retriever_from | string,
**Default:** web_app | Source of retriever | No | #### CompletionMessagePayload @@ -1029,7 +1029,7 @@ Button styles for user actions. | files | [ object ] | Files to be processed | No | | inputs | object | Input variables for the completion | Yes | | query | string | Query text for completion | No | -| response_mode | string | Response mode: blocking or streaming | No | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No | | retriever_from | string,
**Default:** web_app | Source of retriever | No | #### ConversationInfiniteScrollPagination @@ -1322,7 +1322,7 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | content | string | Optional text feedback providing additional detail. | No | -| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFile diff --git a/api/repositories/account_repository.py b/api/repositories/account_repository.py index 1e7fb55e0d3..54568c7321c 100644 --- a/api/repositories/account_repository.py +++ b/api/repositories/account_repository.py @@ -31,6 +31,14 @@ class SQLAlchemyAccountRepository(AccountRepository): account = session.get(Account, account_id) return self._to_snapshot(account) if account is not None else None + @override + def find_by_email(self, email: str) -> AccountSnapshot | None: + with self._session_factory() as session: + account = session.scalar(select(Account).where(Account.email == email).limit(1)) + if account is None and email != email.lower(): + account = session.scalar(select(Account).where(Account.email == email.lower()).limit(1)) + return self._to_snapshot(account) if account is not None else None + @override def get_credentials(self, account_id: str) -> AccountCredentials | None: with self._session_factory() as session: diff --git a/api/repositories/step_by_step_tour_repository.py b/api/repositories/step_by_step_tour_repository.py new file mode 100644 index 00000000000..7dc7a6d6bf2 --- /dev/null +++ b/api/repositories/step_by_step_tour_repository.py @@ -0,0 +1,189 @@ +"""SQLAlchemy repository for account Step-by-step Tour state.""" + +import logging +from collections.abc import Callable +from typing import Protocol, override, runtime_checkable + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from models.onboarding import AccountStepByStepTourState +from services.entities.onboarding_entities import StepByStepTourState +from services.step_by_step_tour_service import StepByStepTourStateRepository + +logger = logging.getLogger(__name__) + +_MYSQL_RETRYABLE_LOCK_ERRNOS = frozenset({1205, 1213}) +_MAX_LOCK_ATTEMPTS = 3 + + +@runtime_checkable +class _ErrorWithErrno(Protocol): + @property + def errno(self) -> object: ... + + +class SQLAlchemyStepByStepTourStateRepository(StepByStepTourStateRepository): + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def get(self, account_id: str) -> StepByStepTourState | None: + with self._session_factory() as session: + model = self._get_model(account_id, session=session) + return self._to_state(model) if model is not None else None + + @override + def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: + """Create state with its first workspace, or atomically claim a legacy empty state.""" + return self._run_with_lock_retry( + lambda: self._initialize_once(account_id, first_workspace_id), + ) + + def _initialize_once(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: + with self._session_factory() as session: + model = self._get_model(account_id, session=session) + if model is None: + model = AccountStepByStepTourState( + account_id=account_id, + first_workspace_id=first_workspace_id, + ) + session.add(model) + try: + session.commit() + except IntegrityError: + # A concurrent request inserted the account-owned row first. + session.rollback() + model = self._get_model(account_id, session=session) + if model is None: + raise + else: + session.refresh(model) + return self._to_state(model) + + if model.first_workspace_id is None: + stmt = ( + update(AccountStepByStepTourState) + .where( + AccountStepByStepTourState.account_id == account_id, + AccountStepByStepTourState.first_workspace_id.is_(None), + ) + .values(first_workspace_id=first_workspace_id) + .execution_options(synchronize_session=False) + ) + session.execute(stmt) + session.commit() + # A competing conditional update may have won while this request waited. + session.refresh(model) + + return self._to_state(model) + + @override + def mutate( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: + """Lock, create if needed, mutate, and persist account state in one transaction.""" + return self._run_with_lock_retry( + lambda: self._mutate_once(account_id, mutation), + ) + + def _mutate_once( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: + with self._session_factory() as session: + # Probe without a locking read so a missing MySQL unique key does not + # acquire a gap/next-key lock before the insert. + model = self._get_model(account_id, session=session) + if model is None: + model = AccountStepByStepTourState(account_id=account_id) + session.add(model) + try: + session.flush() + except IntegrityError: + # A concurrent mutation created the row. Start a new transaction, + # lock its committed state, and replay the pure mutation on it. + session.rollback() + model = self._get_model(account_id, session=session, lock_for_update=True) + if model is None: + raise + else: + model = self._get_model(account_id, session=session, lock_for_update=True) + if model is None: + raise RuntimeError("Step-by-step Tour state disappeared while acquiring its lock") + + state = mutation(self._to_state(model)) + if state.account_id != account_id: + raise ValueError("Step-by-step Tour mutation cannot change account ownership") + # first_workspace_id is write-once and owned exclusively by initialize(). + model.skipped = state.skipped + model.completed_task_ids = list(state.completed_task_ids) + model.manually_enabled_workspace_ids = list(state.manually_enabled_workspace_ids) + model.manually_disabled_workspace_ids = list(state.manually_disabled_workspace_ids) + session.commit() + session.refresh(model) + return self._to_state(model) + + @staticmethod + def _run_with_lock_retry[T](operation: Callable[[], T]) -> T: + for attempt in range(1, _MAX_LOCK_ATTEMPTS): + try: + return operation() + except OperationalError as exc: + if not _is_retryable_mysql_lock_error(exc): + raise + logger.warning( + "Retrying Step-by-step Tour transaction after MySQL lock failure (attempt %s/%s)", + attempt, + _MAX_LOCK_ATTEMPTS, + ) + return operation() + + @staticmethod + def _get_model( + account_id: str, + *, + session: Session, + lock_for_update: bool = False, + ) -> AccountStepByStepTourState | None: + stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1) + if lock_for_update: + stmt = stmt.with_for_update().execution_options(populate_existing=True) + return session.execute(stmt).scalar_one_or_none() + + @staticmethod + def _to_state(model: AccountStepByStepTourState) -> StepByStepTourState: + return StepByStepTourState( + account_id=model.account_id, + first_workspace_id=model.first_workspace_id, + skipped=model.skipped, + completed_task_ids=tuple(model.completed_task_ids), + manually_enabled_workspace_ids=tuple(model.manually_enabled_workspace_ids), + manually_disabled_workspace_ids=tuple(model.manually_disabled_workspace_ids), + updated_at=model.updated_at, + ) + + +def _is_retryable_mysql_lock_error(exc: OperationalError) -> bool: + orig = exc.orig + if isinstance(orig, _ErrorWithErrno) and _is_retryable_mysql_lock_error_code(orig.errno): + return True + if not isinstance(orig, BaseException) or not orig.args: + return False + return _is_retryable_mysql_lock_error_code(orig.args[0]) + + +def _is_retryable_mysql_lock_error_code(candidate: object) -> bool: + if isinstance(candidate, bool): + return False + if isinstance(candidate, int): + code = candidate + elif isinstance(candidate, str) and candidate.isdecimal(): + code = int(candidate) + else: + return False + return code in _MYSQL_RETRYABLE_LOCK_ERRNOS diff --git a/api/services/account_email_registration_adapters.py b/api/services/account_email_registration_adapters.py new file mode 100644 index 00000000000..5bd25b33b6e --- /dev/null +++ b/api/services/account_email_registration_adapters.py @@ -0,0 +1,230 @@ +"""Infrastructure adapters for account email registration.""" + +import logging +import secrets +from typing import override + +from redis import RedisError +from sqlalchemy.orm import Session, sessionmaker + +from extensions.ext_redis import RedisClientWrapper +from libs.helper import RateLimiter, TokenManager +from models.account import Account +from services.account_email_registration_service import ( + AccountRegistrationGateway, + AccountRegistrationPolicyGateway, + EmailRegistrationCodeGenerator, + EmailRegistrationNotificationGateway, + EmailRegistrationSecurityGateway, + EmailRegistrationSendLimiter, + EmailRegistrationTokenGateway, +) +from services.account_errors import ( + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, + AccountNormalizedEmailAlreadyInUseError, + EmailRegistrationSeatsLimitError, +) +from services.account_service import AccountService +from services.billing_service import BillingService +from services.entities.account_entities import ( + AccountEmailRegistrationPhase, + AccountEmailRegistrationToken, + AccountSessionTokens, +) +from services.errors.account import ( + AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError, +) +from services.errors.account import AccountRegisterError, EmailDomainSuspendedError, SeatsLimitExceededError +from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist + +logger = logging.getLogger(__name__) + + +class TokenManagerEmailRegistrationTokenGateway(EmailRegistrationTokenGateway): + @override + def get(self, token: str) -> AccountEmailRegistrationToken | None: + payload = TokenManager.get_token_data(token, "email_register") + if payload is None: + return None + email = payload.get("email") + code = payload.get("code") + phase_value = payload.get("phase") + if not isinstance(email, str) or not isinstance(code, str): + return None + if phase_value is None: + phase = None + else: + try: + phase = AccountEmailRegistrationPhase(phase_value) + except (TypeError, ValueError): + return None + return AccountEmailRegistrationToken(email=email, code=code, phase=phase) + + @override + def issue(self, token_data: AccountEmailRegistrationToken) -> str: + additional_data = {"code": token_data.code} + if token_data.phase is not None: + additional_data["phase"] = token_data.phase.value + return TokenManager.generate_token( + email=token_data.email, + token_type="email_register", + additional_data=additional_data, + ) + + @override + def revoke(self, token: str) -> None: + TokenManager.revoke_token(token, "email_register") + + +class SecureEmailRegistrationCodeGenerator(EmailRegistrationCodeGenerator): + @override + def generate(self) -> str: + return "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)) + + +class CeleryEmailRegistrationNotificationGateway(EmailRegistrationNotificationGateway): + @override + def send_code(self, *, email: str, code: str, language: str) -> None: + send_email_register_mail_task.delay(language=language, to=email, code=code) + + @override + def send_account_exists(self, *, email: str, account_name: str, language: str) -> None: + send_email_register_mail_task_when_account_exist.delay( + language=language, + to=email, + account_name=account_name, + ) + + +class RateLimiterEmailRegistrationSendLimiter(EmailRegistrationSendLimiter): + def __init__(self, *, rate_limiter: RateLimiter) -> None: + self._rate_limiter = rate_limiter + + @override + def is_limited(self, email: str) -> bool: + return self._rate_limiter.is_rate_limited(email) + + @override + def record(self, email: str) -> None: + self._rate_limiter.increment_rate_limit(email) + + @property + @override + def retry_after_minutes(self) -> int: + return int(self._rate_limiter.time_window / 60) + + +class RedisEmailRegistrationSecurityGateway(EmailRegistrationSecurityGateway): + def __init__( + self, + *, + redis: RedisClientWrapper, + verification_failure_limit: int, + verification_lockout_duration: int, + ) -> None: + self._redis = redis + self._verification_failure_limit = verification_failure_limit + self._verification_lockout_duration = verification_lockout_duration + + @override + def is_ip_limited(self, ip_address: str) -> bool: + return AccountService.is_email_send_ip_limit(ip_address) is True + + @override + def is_verification_limited(self, email: str) -> bool: + try: + count = self._redis.get(self._verification_key(email)) + return count is not None and int(count) > self._verification_failure_limit + except RedisError: + logger.warning("Failed to read email-registration verification limit", exc_info=True) + return False + + @override + def record_verification_failure(self, email: str) -> None: + try: + key = self._verification_key(email) + count = int(self._redis.get(key) or 0) + 1 + self._redis.setex(key, self._verification_lockout_duration, count) + except RedisError: + logger.warning("Failed to record email-registration verification failure", exc_info=True) + return None + + @override + def reset_verification_failures(self, email: str) -> None: + try: + self._redis.delete(self._verification_key(email)) + except RedisError: + logger.warning("Failed to reset email-registration verification failures", exc_info=True) + return None + + @override + def reset_login_failures(self, email: str) -> None: + AccountService.reset_login_error_rate_limit(email) + + @staticmethod + def _verification_key(email: str) -> str: + return f"email_register_error_rate_limit:{email}" + + +class BillingAccountRegistrationPolicyGateway(AccountRegistrationPolicyGateway): + def __init__(self, *, enabled: bool) -> None: + self._enabled = enabled + + @override + def get_freeze_type(self, email: str) -> str | None: + if not self._enabled: + return None + return BillingService.get_email_freeze_type(email) + + +class AccountServiceRegistrationGateway(AccountRegistrationGateway): + """Compatibility adapter around account provisioning and login internals.""" + + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def create( + self, + *, + email: str, + password: str, + interface_language: str, + timezone: str | None, + ip_address: str, + ) -> str: + with self._session_factory() as session: + try: + account = AccountService.create_account_and_tenant( + email=email, + name=email, + password=password, + interface_language=interface_language, + timezone=timezone, + ip_address=ip_address, + check_normalized_email=True, + session=session, + ) + except SeatsLimitExceededError as exc: + raise EmailRegistrationSeatsLimitError from exc + except EmailDomainSuspendedError as exc: + raise AccountEmailDomainSuspendedError from exc + except AccountNormalizedEmailAlreadyInUseServiceError as exc: + raise AccountNormalizedEmailAlreadyInUseError from exc + except AccountRegisterError as exc: + raise AccountEmailFrozenError from exc + return account.id + + @override + def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: + with self._session_factory() as session: + account = session.get(Account, account_id) + if account is None: + raise RuntimeError("newly registered account no longer exists") + token_pair = AccountService.login(account=account, session=session, ip_address=ip_address) + return AccountSessionTokens( + access_token=token_pair.access_token, + refresh_token=token_pair.refresh_token, + csrf_token=token_pair.csrf_token, + ) diff --git a/api/services/account_email_registration_service.py b/api/services/account_email_registration_service.py new file mode 100644 index 00000000000..2379f220254 --- /dev/null +++ b/api/services/account_email_registration_service.py @@ -0,0 +1,207 @@ +"""Application service for the account email-registration use case.""" + +from typing import Protocol + +from constants.languages import get_valid_language, languages +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, + EmailRegistrationPasswordMismatchError, + EmailRegistrationSendIPLimitedError, + EmailRegistrationSendRateLimitError, + EmailRegistrationVerificationLimitError, + InvalidEmailRegistrationAddressError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, +) +from services.account_ports import AccountRepository +from services.entities.account_entities import ( + AccountEmailRegistrationPhase, + AccountEmailRegistrationToken, + AccountEmailRegistrationVerification, + AccountSessionTokens, +) + + +class EmailRegistrationTokenGateway(Protocol): + def get(self, token: str) -> AccountEmailRegistrationToken | None: ... + + def issue(self, token_data: AccountEmailRegistrationToken) -> str: ... + + def revoke(self, token: str) -> None: ... + + +class EmailRegistrationCodeGenerator(Protocol): + def generate(self) -> str: ... + + +class EmailRegistrationNotificationGateway(Protocol): + def send_code(self, *, email: str, code: str, language: str) -> None: ... + + def send_account_exists(self, *, email: str, account_name: str, language: str) -> None: ... + + +class EmailRegistrationSendLimiter(Protocol): + def is_limited(self, email: str) -> bool: ... + + def record(self, email: str) -> None: ... + + @property + def retry_after_minutes(self) -> int: ... + + +class EmailRegistrationSecurityGateway(Protocol): + def is_ip_limited(self, ip_address: str) -> bool: ... + + def is_verification_limited(self, email: str) -> bool: ... + + def record_verification_failure(self, email: str) -> None: ... + + def reset_verification_failures(self, email: str) -> None: ... + + def reset_login_failures(self, email: str) -> None: ... + + +class AccountRegistrationPolicyGateway(Protocol): + def get_freeze_type(self, email: str) -> str | None: ... + + +class AccountRegistrationGateway(Protocol): + def create( + self, + *, + email: str, + password: str, + interface_language: str, + timezone: str | None, + ip_address: str, + ) -> str: ... + + def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: ... + + +class AccountEmailRegistrationService: + def __init__( + self, + *, + accounts: AccountRepository, + tokens: EmailRegistrationTokenGateway, + codes: EmailRegistrationCodeGenerator, + notifications: EmailRegistrationNotificationGateway, + send_limits: EmailRegistrationSendLimiter, + security: EmailRegistrationSecurityGateway, + account_policy: AccountRegistrationPolicyGateway, + registration: AccountRegistrationGateway, + ) -> None: + self._accounts = accounts + self._tokens = tokens + self._codes = codes + self._notifications = notifications + self._send_limits = send_limits + self._security = security + self._account_policy = account_policy + self._registration = registration + + def send_code( + self, + *, + remote_ip: str, + requested_email: str, + requested_language: str | None, + ) -> str: + if self._security.is_ip_limited(remote_ip): + raise EmailRegistrationSendIPLimitedError + + normalized_email = requested_email.lower() + self._ensure_email_allowed(normalized_email) + account = self._accounts.find_by_email(requested_email) + delivery_email = account.email if account is not None else normalized_email + if self._send_limits.is_limited(delivery_email): + raise EmailRegistrationSendRateLimitError(self._send_limits.retry_after_minutes) + + language = requested_language if requested_language is not None and requested_language in languages else "en-US" + code = self._codes.generate() + token = self._tokens.issue(AccountEmailRegistrationToken(email=delivery_email, code=code)) + if account is None: + self._notifications.send_code(email=delivery_email, code=code, language=language) + else: + self._notifications.send_account_exists( + email=delivery_email, + account_name=account.name, + language=language, + ) + self._send_limits.record(delivery_email) + return token + + def verify_code( + self, + *, + email: str, + code: str, + token: str, + ) -> AccountEmailRegistrationVerification: + normalized_email = email.lower() + if self._security.is_verification_limited(normalized_email): + raise EmailRegistrationVerificationLimitError + + token_data = self._tokens.get(token) + if token_data is None: + raise InvalidEmailRegistrationTokenError + normalized_token_email = token_data.email.lower() + if normalized_email != normalized_token_email: + raise InvalidEmailRegistrationAddressError + if code != token_data.code: + self._security.record_verification_failure(normalized_email) + raise InvalidEmailRegistrationCodeError + + self._tokens.revoke(token) + verified_token = self._tokens.issue( + AccountEmailRegistrationToken( + email=normalized_email, + code=code, + phase=AccountEmailRegistrationPhase.REGISTER, + ) + ) + self._security.reset_verification_failures(normalized_email) + return AccountEmailRegistrationVerification(email=normalized_token_email, token=verified_token) + + def register( + self, + *, + remote_ip: str, + token: str, + new_password: str, + password_confirm: str, + language: str | None, + timezone: str | None, + ) -> AccountSessionTokens: + if new_password != password_confirm: + raise EmailRegistrationPasswordMismatchError + + token_data = self._tokens.get(token) + if token_data is None or token_data.phase != AccountEmailRegistrationPhase.REGISTER: + raise InvalidEmailRegistrationTokenError + self._tokens.revoke(token) + + normalized_email = token_data.email.lower() + if self._accounts.find_by_email(token_data.email) is not None: + raise AccountEmailAlreadyInUseError + + account_id = self._registration.create( + email=normalized_email, + password=password_confirm, + interface_language=get_valid_language(language), + timezone=timezone, + ip_address=remote_ip, + ) + tokens = self._registration.login(account_id, ip_address=remote_ip) + self._security.reset_login_failures(normalized_email) + return tokens + + def _ensure_email_allowed(self, email: str) -> None: + freeze_type = self._account_policy.get_freeze_type(email) + if freeze_type == "email_domain_suspended": + raise AccountEmailDomainSuspendedError + if freeze_type: + raise AccountEmailFrozenError diff --git a/api/services/account_errors.py b/api/services/account_errors.py index c902c7d331b..115e0e6f511 100644 --- a/api/services/account_errors.py +++ b/api/services/account_errors.py @@ -85,6 +85,46 @@ class AccountEmailAlreadyInUseError(AccountApplicationError): """The target email already belongs to an account.""" +class AccountNormalizedEmailAlreadyInUseError(AccountEmailAlreadyInUseError): + """A normalized equivalent of the target email already belongs to an account.""" + + +class EmailRegistrationSendIPLimitedError(AccountApplicationError): + """The caller IP exceeded the registration-email send policy.""" + + +class EmailRegistrationSendRateLimitError(AccountApplicationError): + """Too many registration messages were requested for the address.""" + + def __init__(self, retry_after_minutes: int) -> None: + super().__init__(retry_after_minutes) + self.retry_after_minutes = retry_after_minutes + + +class EmailRegistrationVerificationLimitError(AccountApplicationError): + """Too many invalid registration-code attempts were made.""" + + +class InvalidEmailRegistrationTokenError(AccountApplicationError): + """The registration token is absent, malformed, or in the wrong phase.""" + + +class InvalidEmailRegistrationAddressError(AccountApplicationError): + """The request address does not match the registration token.""" + + +class InvalidEmailRegistrationCodeError(AccountApplicationError): + """The verification code does not match the registration token.""" + + +class EmailRegistrationPasswordMismatchError(AccountApplicationError): + """The registration password confirmation does not match.""" + + +class EmailRegistrationSeatsLimitError(AccountApplicationError): + """The deployment has no licensed seat available for another account.""" + + class EducationDiscountPausedError(AccountApplicationError): """Education discount activation is temporarily paused.""" diff --git a/api/services/account_ports.py b/api/services/account_ports.py index 39afd92bd1f..78792664127 100644 --- a/api/services/account_ports.py +++ b/api/services/account_ports.py @@ -19,6 +19,8 @@ from services.entities.account_entities import ( class AccountRepository(Protocol): def get(self, account_id: str) -> AccountSnapshot | None: ... + def find_by_email(self, email: str) -> AccountSnapshot | None: ... + def get_credentials(self, account_id: str) -> AccountCredentials | None: ... def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ... diff --git a/api/services/account_service.py b/api/services/account_service.py index a60c2007912..b022187e14d 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -93,7 +93,6 @@ from tasks.mail_owner_transfer_task import ( send_old_owner_transfer_notify_email_task, send_owner_transfer_confirm_task, ) -from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist from tasks.mail_reset_password_task import ( send_reset_password_mail_task, send_reset_password_mail_task_when_account_not_exist, @@ -157,7 +156,6 @@ class AccountService: CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) - email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) email_code_login_rate_limiter = RateLimiter( prefix="email_code_login_rate_limit", max_attempts=3, time_window=300 * 1 ) @@ -168,7 +166,16 @@ class AccountService: FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 - EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 + + @staticmethod + def _resolve_role_id_by_tag(tenant_id: str, account_id: str, tag: str) -> str: + options = ListOption(page_number=1, results_per_page=100) + roles = RBACService.Roles.list(tenant_id, account_id, options=options).data + for rbac_role in roles: + if rbac_role.is_builtin and rbac_role.category == "global_system_default" and rbac_role.role_tag == tag: + return str(rbac_role.id) + + raise ValueError(f"Builtin RBAC role not found for tag {tag!r} in tenant {tenant_id}") @staticmethod def _resolve_legacy_role_id(tenant_id: str, account_id: str, role: TenantAccountRole) -> str: @@ -177,9 +184,6 @@ class AccountService: Looks up the builtin RBAC role whose tag matches the legacy role name (e.g. ``TenantAccountRole.ADMIN`` → builtin role with tag ``"admin"``). """ - options = ListOption(page_number=1, results_per_page=100) - roles = RBACService.Roles.list(tenant_id, account_id, options=options).data - expected_tag = { TenantAccountRole.OWNER: "owner", TenantAccountRole.ADMIN: "admin", @@ -187,15 +191,7 @@ class AccountService: TenantAccountRole.NORMAL: "normal", TenantAccountRole.DATASET_OPERATOR: "dataset_operator", }[role] - for rbac_role in roles: - if ( - rbac_role.is_builtin - and rbac_role.category == "global_system_default" - and rbac_role.role_tag == expected_tag - ): - return str(rbac_role.id) - - raise ValueError(f"Builtin RBAC role not found for {role.value} in tenant {tenant_id}") + return AccountService._resolve_role_id_by_tag(tenant_id, account_id, expected_tag) @staticmethod def get_workspace_permission_keys(tenant_id: str, account_id: str, *, session: Session) -> set[str]: @@ -680,40 +676,6 @@ class AccountService: cls.reset_password_rate_limiter.increment_rate_limit(account_email) return token - @classmethod - def send_email_register_email( - cls, - account: Account | None = None, - email: str | None = None, - language: str = "en-US", - ): - account_email = account.email if account else email - if account_email is None: - raise ValueError("Email must be provided.") - - if cls.email_register_rate_limiter.is_rate_limited(account_email): - from controllers.console.auth.error import EmailRegisterRateLimitExceededError - - raise EmailRegisterRateLimitExceededError(int(cls.email_register_rate_limiter.time_window / 60)) - - code, token = cls.generate_email_register_token(account_email) - - if account: - send_email_register_mail_task_when_account_exist.delay( - language=language, - to=account_email, - account_name=account.name, - ) - - else: - send_email_register_mail_task.delay( - language=language, - to=account_email, - code=code, - ) - cls.email_register_rate_limiter.increment_rate_limit(account_email) - return token - @classmethod def send_change_email_email( cls, @@ -867,19 +829,6 @@ class AccountService: ) return code, token - @classmethod - def generate_email_register_token( - cls, - email: str, - code: str | None = None, - additional_data: dict[str, Any] = {}, - ): - if not code: - code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) - additional_data["code"] = code - token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data) - return code, token - @classmethod def generate_change_email_token( cls, @@ -917,10 +866,6 @@ class AccountService: def revoke_reset_password_token(cls, token: str): TokenManager.revoke_token(token, "reset_password") - @classmethod - def revoke_email_register_token(cls, token: str): - TokenManager.revoke_token(token, "email_register") - @classmethod def revoke_change_email_token(cls, token: str): TokenManager.revoke_token(token, "change_email") @@ -933,10 +878,6 @@ class AccountService: def get_reset_password_data(cls, token: str) -> dict[str, Any] | None: return TokenManager.get_token_data(token, "reset_password") - @classmethod - def get_email_register_data(cls, token: str) -> dict[str, Any] | None: - return TokenManager.get_token_data(token, "email_register") - @classmethod def get_change_email_data(cls, token: str) -> ChangeEmailTokenData | None: token_data = TokenManager.get_token_data(token, "change_email") @@ -1067,16 +1008,6 @@ class AccountService: count = int(count) + 1 redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count) - @staticmethod - @redis_fallback(default_return=None) - def add_email_register_error_rate_limit(email: str) -> None: - key = f"email_register_error_rate_limit:{email}" - count = redis_client.get(key) - if count is None: - count = 0 - count = int(count) + 1 - redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count) - @staticmethod @redis_fallback(default_return=False) def is_forgot_password_error_rate_limit(email: str) -> bool: @@ -1096,24 +1027,6 @@ class AccountService: key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) - @staticmethod - @redis_fallback(default_return=False) - def is_email_register_error_rate_limit(email: str) -> bool: - key = f"email_register_error_rate_limit:{email}" - count = redis_client.get(key) - if count is None: - return False - count = int(count) - if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS: - return True - return False - - @staticmethod - @redis_fallback(default_return=None) - def reset_email_register_error_rate_limit(email: str): - key = f"email_register_error_rate_limit:{email}" - redis_client.delete(key) - @staticmethod @redis_fallback(default_return=None) def add_change_email_error_rate_limit(email: str): @@ -1857,28 +1770,39 @@ class TenantService: raise RoleAlreadyAssignedError("The provided role is already assigned to the member.") if new_role == "owner": - # Find the current owner and change their role to 'admin' + if dify_config.RBAC_ENABLED: + old_owner_id = AccountService.get_rbac_workspace_owner_account_id( + str(tenant.id), operator.id, session=session + ) + owner_role_id = AccountService._resolve_legacy_role_id( + tenant_id=str(tenant.id), + account_id=operator.id, + role=TenantAccountRole.OWNER, + ) + no_access_role_id = AccountService._resolve_role_id_by_tag( + tenant_id=str(tenant.id), + account_id=operator.id, + tag="no_access", + ) + current_roles = RBACService.MemberRoles.get( + str(tenant.id), operator.id, old_owner_id, session=session + ).roles + remaining_role_ids = [str(r.id) for r in current_roles if str(r.id) != owner_role_id] + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=operator.id, + member_account_id=old_owner_id, + role_ids=remaining_role_ids or [no_access_role_id], + session=session, + ) + current_owner_join = session.scalar( select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") .limit(1) ) - if not dify_config.RBAC_ENABLED: - if current_owner_join: - current_owner_join.role = TenantAccountRole.ADMIN - elif current_owner_join: - admin_role_id = AccountService._resolve_legacy_role_id( - tenant_id=str(tenant.id), - account_id=operator.id, - role=TenantAccountRole.ADMIN, - ) - RBACService.MemberRoles.replace( - tenant_id=str(tenant.id), - account_id=operator.id, - member_account_id=str(current_owner_join.account_id), - role_ids=[admin_role_id], - session=session, - ) + if current_owner_join: + current_owner_join.role = TenantAccountRole.NORMAL # Update the role of the target member if dify_config.RBAC_ENABLED: @@ -1894,6 +1818,8 @@ class TenantService: role_ids=[resolved_role_id], session=session, ) + if new_tenant_role == TenantAccountRole.OWNER: + target_member_join.role = new_tenant_role else: target_member_join.role = new_tenant_role session.commit() diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index 087bbd9be2b..ac0de983eb9 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -120,7 +120,7 @@ class AppAnnotationService: raw_message_id = args.get("message_id") if raw_message_id: - message_id = str(raw_message_id) + message_id = raw_message_id message = session.scalar(select(Message).where(Message.id == message_id, Message.app_id == app.id).limit(1)) if not message: @@ -176,19 +176,19 @@ class AppAnnotationService: @classmethod def enable_app_annotation(cls, args: EnableAnnotationArgs, app_id: str) -> AnnotationJobStatusDict: - enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}" + enable_app_annotation_key = f"enable_app_annotation_{app_id}" cache_result = redis_client.get(enable_app_annotation_key) if cache_result is not None: return {"job_id": cache_result, "job_status": "processing"} # async job job_id = str(uuid.uuid4()) - enable_app_annotation_job_key = f"enable_app_annotation_job_{str(job_id)}" + enable_app_annotation_job_key = f"enable_app_annotation_job_{job_id}" # send batch add segments task redis_client.setnx(enable_app_annotation_job_key, "waiting") current_user, current_tenant_id = current_account_with_tenant() enable_annotation_reply_task.delay( - str(job_id), + job_id, app_id, current_user.id, current_tenant_id, @@ -201,17 +201,17 @@ class AppAnnotationService: @classmethod def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict: _, current_tenant_id = current_account_with_tenant() - disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}" + disable_app_annotation_key = f"disable_app_annotation_{app_id}" cache_result = redis_client.get(disable_app_annotation_key) if cache_result is not None: return {"job_id": cache_result, "job_status": "processing"} # async job job_id = str(uuid.uuid4()) - disable_app_annotation_job_key = f"disable_app_annotation_job_{str(job_id)}" + disable_app_annotation_job_key = f"disable_app_annotation_job_{job_id}" # send batch add segments task redis_client.setnx(disable_app_annotation_job_key, "waiting") - disable_annotation_reply_task.delay(str(job_id), app_id, current_tenant_id) + disable_annotation_reply_task.delay(job_id, app_id, current_tenant_id) return {"job_id": job_id, "job_status": "waiting"} @classmethod @@ -539,7 +539,7 @@ class AppAnnotationService: raise ValueError("The number of annotations exceeds the limit of your subscription.") # async job job_id = str(uuid.uuid4()) - indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}" + indexing_cache_key = f"app_annotation_batch_import_{job_id}" # Register job in active tasks list for concurrency tracking current_time = int(naive_utc_now().timestamp() * 1000) @@ -549,7 +549,7 @@ class AppAnnotationService: # Set job status redis_client.setnx(indexing_cache_key, "waiting") - batch_import_annotations_task.delay(str(job_id), result, app_id, current_tenant_id, current_user.id) + batch_import_annotations_task.delay(job_id, result, app_id, current_tenant_id, current_user.id) except ValueError as e: return {"error_msg": str(e)} diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 4b271c6e94e..a22a7ba5d47 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -21,11 +21,17 @@ from core.app.features.rate_limiting import RateLimit from core.app.features.rate_limiting.rate_limit import rate_limit_context from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.db import session_factory +from core.trigger.constants import is_trigger_node_type from enums import DeploymentEdition, QuotaType from extensions.otel import AppGenerateHandler, trace_span from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun -from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.app import ( + QuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) from services.errors.llm import InvokeRateLimitError from services.quota_service import QuotaService, unlimited from services.workflow_service import WorkflowService @@ -34,6 +40,13 @@ from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflo logger = logging.getLogger(__name__) SSE_TASK_START_FALLBACK_MS = 200 +_MANUAL_WORKFLOW_INVOKE_SOURCES = frozenset( + { + InvokeFrom.OPENAPI, + InvokeFrom.SERVICE_API, + InvokeFrom.WEB_APP, + } +) if TYPE_CHECKING: from controllers.console.app.workflow import LoopNodeRunPayload @@ -290,6 +303,7 @@ class AppGenerateService: case AppMode.WORKFLOW: workflow_id = args.get("workflow_id") workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session) + cls._ensure_workflow_service_mode_available(workflow=workflow, invoke_from=invoke_from) if streaming: with rate_limit_context(rate_limit, request_id): payload = AppExecutionParams.new( @@ -343,6 +357,16 @@ class AppGenerateService: case _: raise ValueError(f"Invalid app mode {app_model.mode}") + @staticmethod + def _ensure_workflow_service_mode_available(*, workflow: Workflow, invoke_from: InvokeFrom) -> None: + if invoke_from not in _MANUAL_WORKFLOW_INVOKE_SOURCES: + return + + for _, node_data in workflow.walk_nodes(): + node_type = node_data.get("type") + if isinstance(node_type, str) and is_trigger_node_type(node_type): + raise TriggerWorkflowServiceModeUnavailableError() + @staticmethod def _get_max_active_requests(app: App) -> int: """ diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 004e927b675..7a862478066 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1664,7 +1664,7 @@ class DocumentService: """Fetch documents for a dataset in a single batch query.""" if not document_ids: return [] - document_id_list: list[str] = [str(document_id) for document_id in document_ids] + document_id_list: list[str] = list(document_ids) # Fetch all requested documents in one query to avoid N+1 lookups. documents: Sequence[Document] = session.scalars( select(Document).where( @@ -1700,7 +1700,7 @@ class DocumentService: if not document_ids: return 0 - document_id_list: list[str] = [str(document_id) for document_id in document_ids] + document_id_list: list[str] = list(document_ids) result = session.execute( update(Document) @@ -1861,7 +1861,7 @@ class DocumentService: """ Batch load upload files keyed by document id for ZIP downloads. """ - document_id_list: list[str] = [str(document_id) for document_id in document_ids] + document_id_list: list[str] = list(document_ids) documents = DocumentService.get_documents_by_ids( DatasetRef(tenant_id=tenant_id, dataset_id=dataset_id), document_id_list, session diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index a4d20112724..01db156b3f9 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -475,6 +475,7 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ "plugin.install", "credential.use", "app_library.access", + "agent.manage", ] _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ @@ -482,6 +483,7 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ "plugin.install", "dataset.create_and_management", "dataset.external.connect", + "agent.manage", ] _LEGACY_APP_OWNER_KEYS: list[str] = [ @@ -2001,7 +2003,7 @@ class RBACService: ) ) if current_owner_join and current_owner_join.account_id != member_account_id: - current_owner_join.role = TenantAccountRole.ADMIN + current_owner_join.role = TenantAccountRole.NORMAL target_member_join.role = tenant_role session.commit() diff --git a/api/services/entities/account_entities.py b/api/services/entities/account_entities.py index 21cfc2df00d..b53a739eba2 100644 --- a/api/services/entities/account_entities.py +++ b/api/services/entities/account_entities.py @@ -116,6 +116,30 @@ class AccountEmailResetResult: account: AccountSnapshot | None = None +class AccountEmailRegistrationPhase(StrEnum): + REGISTER = "register" + + +@dataclass(frozen=True, slots=True) +class AccountEmailRegistrationToken: + email: str + code: str + phase: AccountEmailRegistrationPhase | None = None + + +@dataclass(frozen=True, slots=True) +class AccountEmailRegistrationVerification: + email: str + token: str + + +@dataclass(frozen=True, slots=True) +class AccountSessionTokens: + access_token: str + refresh_token: str + csrf_token: str + + class AccountChangeEmailPhase(StrEnum): OLD_EMAIL = "old_email" OLD_EMAIL_VERIFIED = "old_email_verified" diff --git a/api/services/entities/notification_entities.py b/api/services/entities/notification_entities.py new file mode 100644 index 00000000000..6686c5edb99 --- /dev/null +++ b/api/services/entities/notification_entities.py @@ -0,0 +1,38 @@ +"""Framework-independent notification contracts.""" + +from collections.abc import Mapping +from typing import NamedTuple + + +class NotificationContent(NamedTuple): + lang: str + title: str + subtitle: str + body: str + title_pic_url: str + + +class AccountNotification(NamedTuple): + notification_id: str | None + frequency: str | None + contents: Mapping[str, NotificationContent] + + +class AccountNotificationBatch(NamedTuple): + should_show: bool + notifications: tuple[AccountNotification, ...] + + +class NotificationItem(NamedTuple): + notification_id: str | None + frequency: str | None + lang: str + title: str + subtitle: str + body: str + title_pic_url: str + + +class NotificationResult(NamedTuple): + should_show: bool + notifications: tuple[NotificationItem, ...] diff --git a/api/services/entities/onboarding_entities.py b/api/services/entities/onboarding_entities.py new file mode 100644 index 00000000000..2550db489e4 --- /dev/null +++ b/api/services/entities/onboarding_entities.py @@ -0,0 +1,42 @@ +"""Framework-independent Step-by-step Tour contracts.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, TypeAlias + +# Assignment-form aliases preserve Literal enum values in Pydantic-generated OpenAPI schemas. +StepByStepTourAction: TypeAlias = Literal[ # noqa: UP040 + "skip", + "complete_task", + "uncomplete_task", + "enable_current_workspace", + "disable_current_workspace", +] +StepByStepTourTaskId: TypeAlias = Literal["home", "studio", "knowledge", "integration"] # noqa: UP040 + + +@dataclass(frozen=True, slots=True) +class StepByStepTourPatch: + action: StepByStepTourAction + task_id: StepByStepTourTaskId | None = None + + +@dataclass(frozen=True, slots=True) +class StepByStepTourState: + account_id: str + first_workspace_id: str | None = None + skipped: bool = False + completed_task_ids: tuple[str, ...] = () + manually_enabled_workspace_ids: tuple[str, ...] = () + manually_disabled_workspace_ids: tuple[str, ...] = () + updated_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class StepByStepTourResult: + first_workspace_id: str | None = None + skipped: bool = False + completed_task_ids: tuple[str, ...] = () + manually_enabled_workspace_ids: tuple[str, ...] = () + manually_disabled_workspace_ids: tuple[str, ...] = () + updated_at: datetime | None = None diff --git a/api/services/errors/app.py b/api/services/errors/app.py index c9e9df97dea..74c29b0857d 100644 --- a/api/services/errors/app.py +++ b/api/services/errors/app.py @@ -18,6 +18,21 @@ class WorkflowIdFormatError(Exception): pass +TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE = "trigger_workflow_service_mode_unavailable" +TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE = ( + "This workflow uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP." +) + + +class TriggerWorkflowServiceModeUnavailableError(Exception): + """Raised when a trigger-entry Workflow is invoked through a manual service surface.""" + + error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE + + def __init__(self) -> None: + super().__init__(TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE) + + class QuotaExceededError(ValueError): """Raised when billing quota is exceeded for a feature.""" diff --git a/api/services/notification_gateway.py b/api/services/notification_gateway.py new file mode 100644 index 00000000000..cb7cc5e74d0 --- /dev/null +++ b/api/services/notification_gateway.py @@ -0,0 +1,48 @@ +"""Billing-backed notification gateway.""" + +from collections.abc import Mapping +from typing import Any, override + +from services.billing_service import BillingService +from services.entities.notification_entities import ( + AccountNotification, + AccountNotificationBatch, + NotificationContent, +) +from services.notification_service import NotificationGateway + + +class BillingNotificationGateway(NotificationGateway): + @override + def get_active(self, account_id: str) -> AccountNotificationBatch: + payload = BillingService.get_account_notification(account_id) + notifications = tuple(self._map_notification(item) for item in payload.get("notifications") or ()) + return AccountNotificationBatch( + should_show=bool(payload.get("shouldShow")), + notifications=notifications, + ) + + @override + def dismiss(self, notification_id: str, account_id: str) -> None: + BillingService.dismiss_notification(notification_id=notification_id, account_id=account_id) + + @classmethod + def _map_notification(cls, payload: Mapping[str, Any]) -> AccountNotification: + raw_contents = payload.get("contents") or {} + contents = {language: cls._map_content(content) for language, content in raw_contents.items() if content} + return AccountNotification( + notification_id=payload.get("notificationId"), + frequency=payload.get("frequency"), + contents=contents, + ) + + @staticmethod + def _map_content(payload: Mapping[str, Any]) -> NotificationContent: + return NotificationContent( + # The application service owns the requested-language fallback. + lang=payload.get("lang") or "", + title=payload.get("title") or "", + subtitle=payload.get("subtitle") or "", + body=payload.get("body") or "", + title_pic_url=payload.get("titlePicUrl") or "", + ) diff --git a/api/services/notification_service.py b/api/services/notification_service.py new file mode 100644 index 00000000000..13236ef16ed --- /dev/null +++ b/api/services/notification_service.py @@ -0,0 +1,60 @@ +"""Application service for Console account notifications.""" + +from typing import Protocol + +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.notification_entities import ( + AccountNotification, + AccountNotificationBatch, + NotificationContent, + NotificationItem, + NotificationResult, +) + +_FALLBACK_LANGUAGE = "en-US" + + +class NotificationGateway(Protocol): + def get_active(self, account_id: str) -> AccountNotificationBatch: ... + + def dismiss(self, notification_id: str, account_id: str) -> None: ... + + +class NotificationService: + def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None: + self._accounts = accounts + self._notifications = notifications + + def get_active(self, context: RequestContext) -> NotificationResult: + batch = self._notifications.get_active(context.account_id) + if not batch.should_show: + return NotificationResult(should_show=False, notifications=()) + + account = self._accounts.get(context.account_id) + if account is None: + raise RuntimeError("Console account admission resolved an unknown account") + language = account.interface_language or _FALLBACK_LANGUAGE + + notifications = tuple(self._localize(notification, language) for notification in batch.notifications) + return NotificationResult(should_show=bool(notifications), notifications=notifications) + + def dismiss(self, context: RequestContext, notification_id: str) -> None: + self._notifications.dismiss(notification_id, context.account_id) + + @staticmethod + def _localize(notification: AccountNotification, language: str) -> NotificationItem: + content = ( + notification.contents.get(language) + or notification.contents.get(_FALLBACK_LANGUAGE) + or next(iter(notification.contents.values()), NotificationContent(language, "", "", "", "")) + ) + return NotificationItem( + notification_id=notification.notification_id, + frequency=notification.frequency, + lang=content.lang or language, + title=content.title, + subtitle=content.subtitle, + body=content.body, + title_pic_url=content.title_pic_url, + ) diff --git a/api/services/step_by_step_tour_service.py b/api/services/step_by_step_tour_service.py index b01d59c1acc..9597d3d5e77 100644 --- a/api/services/step_by_step_tour_service.py +++ b/api/services/step_by_step_tour_service.py @@ -1,221 +1,161 @@ -"""Account-level Step-by-step Tour persistence.""" +"""Application service for account-level Step-by-step Tour use cases.""" +from collections.abc import Callable +from dataclasses import replace from datetime import datetime -from typing import NotRequired, TypedDict +from typing import Protocol, get_args -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, scoped_session - -from configs import dify_config from libs.datetime_utils import ensure_naive_utc -from models.account import Account -from models.onboarding import AccountStepByStepTourState +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.onboarding_entities import ( + StepByStepTourPatch, + StepByStepTourResult, + StepByStepTourState, + StepByStepTourTaskId, +) -STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration")) +_TASK_IDS: frozenset[str] = frozenset(get_args(StepByStepTourTaskId)) -class StepByStepTourStateResponse(TypedDict): - first_workspace_id: str | None - skipped: bool - completed_task_ids: list[str] - manually_enabled_workspace_ids: list[str] - manually_disabled_workspace_ids: list[str] - updated_at: datetime | None +class StepByStepTourStateRepository(Protocol): + def get(self, account_id: str) -> StepByStepTourState | None: ... + def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: ... -class StepByStepTourPatch(TypedDict): - action: str - task_id: NotRequired[str | None] + def mutate( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: ... class StepByStepTourService: - """Coordinate persisted tour state with account eligibility rules.""" - - @classmethod - def get_state( - cls, + def __init__( + self, *, - account: Account, - current_tenant_id: str, - session: Session | scoped_session, - ) -> StepByStepTourStateResponse: - eligible = cls.is_eligible(account) - state = cls._get_state(account.id, session=session) + accounts: AccountRepository, + states: StepByStepTourStateRepository, + enabled: bool, + rollout_started_at: datetime | None, + ) -> None: + self._accounts = accounts + self._states = states + self._enabled = enabled + self._rollout_started_at = rollout_started_at - if eligible: - state = cls._ensure_state(account.id, session=session, state=state) - if state.first_workspace_id is None: - state.first_workspace_id = current_tenant_id - session.commit() - session.refresh(state) + def get_state(self, context: RequestContext) -> StepByStepTourResult: + workspace_id = self._require_workspace(context) + account = self._accounts.get(context.account_id) + if account is None: + raise RuntimeError("Console account admission resolved an unknown account") - return cls._build_response(state=state) + if not self._is_eligible(account.initialized_at or account.created_at): + return self._to_result(self._states.get(context.account_id)) - @classmethod - def patch_state( - cls, - *, - account: Account, - current_tenant_id: str, - patch: StepByStepTourPatch, - session: Session | scoped_session, - ) -> StepByStepTourStateResponse: - state = cls._ensure_state(account.id, session=session, state=None) - cls._apply_action( - state=state, - action=patch["action"], - task_id=patch.get("task_id"), - current_tenant_id=current_tenant_id, + return self._to_result(self._states.initialize(context.account_id, workspace_id)) + + def patch_state(self, context: RequestContext, patch: StepByStepTourPatch) -> StepByStepTourResult: + workspace_id = self._require_workspace(context) + state = self._states.mutate( + context.account_id, + lambda current: self._apply_action(current, patch=patch, workspace_id=workspace_id), ) + return self._to_result(state) - session.commit() - session.refresh(state) - return cls._build_response(state=state) - - @classmethod - def is_eligible(cls, account: Account) -> bool: - if not dify_config.ENABLE_STEP_BY_STEP_TOUR: + def _is_eligible(self, account_started_at: datetime) -> bool: + if not self._enabled or self._rollout_started_at is None: return False - - rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT - if rollout_started_at is None: - return False - - account_started_at = account.initialized_at or account.created_at - if account_started_at is None: - return False - - return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at) - - @classmethod - def _get_state( - cls, - account_id: str, - *, - session: Session | scoped_session, - ) -> AccountStepByStepTourState | None: - stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1) - return session.execute(stmt).scalar_one_or_none() - - @classmethod - def _ensure_state( - cls, - account_id: str, - *, - session: Session | scoped_session, - state: AccountStepByStepTourState | None, - ) -> AccountStepByStepTourState: - if state is None: - state = cls._get_state(account_id, session=session) - if state is not None: - return state - - state = AccountStepByStepTourState(account_id=account_id) - session.add(state) - try: - session.flush() - except IntegrityError: - # Another tab/device can create the account row between our read and insert. - session.rollback() - state = cls._get_state(account_id, session=session) - if state is None: - raise - return state + return ensure_naive_utc(account_started_at) >= ensure_naive_utc(self._rollout_started_at) @classmethod def _apply_action( cls, + state: StepByStepTourState, *, - state: AccountStepByStepTourState, - action: str, - task_id: str | None, - current_tenant_id: str, - ) -> None: - match action: + patch: StepByStepTourPatch, + workspace_id: str, + ) -> StepByStepTourState: + match patch.action: case "skip": - state.skipped = True - state.manually_enabled_workspace_ids = cls._remove_id( - state.manually_enabled_workspace_ids, - current_tenant_id, + return replace( + state, + skipped=True, + manually_enabled_workspace_ids=cls._remove_id( + state.manually_enabled_workspace_ids, + workspace_id, + ), ) case "complete_task": - if task_id is None: - raise ValueError("task_id is required") - cls._validate_task_id(task_id) - state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id) + task_id = cls._require_task_id(patch.task_id) + return replace(state, completed_task_ids=cls._add_id(state.completed_task_ids, task_id)) case "uncomplete_task": - if task_id is None: - raise ValueError("task_id is required") - cls._validate_task_id(task_id) - state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id) + task_id = cls._require_task_id(patch.task_id) + return replace(state, completed_task_ids=cls._remove_id(state.completed_task_ids, task_id)) case "enable_current_workspace": - state.skipped = False - state.manually_enabled_workspace_ids = cls._add_id( - state.manually_enabled_workspace_ids, - current_tenant_id, - ) - state.manually_disabled_workspace_ids = cls._remove_id( - state.manually_disabled_workspace_ids, - current_tenant_id, + return replace( + state, + skipped=False, + manually_enabled_workspace_ids=cls._add_id( + state.manually_enabled_workspace_ids, + workspace_id, + ), + manually_disabled_workspace_ids=cls._remove_id( + state.manually_disabled_workspace_ids, + workspace_id, + ), ) case "disable_current_workspace": - state.manually_enabled_workspace_ids = cls._remove_id( - state.manually_enabled_workspace_ids, - current_tenant_id, - ) - state.manually_disabled_workspace_ids = cls._add_id( - state.manually_disabled_workspace_ids, - current_tenant_id, + return replace( + state, + manually_enabled_workspace_ids=cls._remove_id( + state.manually_enabled_workspace_ids, + workspace_id, + ), + manually_disabled_workspace_ids=cls._add_id( + state.manually_disabled_workspace_ids, + workspace_id, + ), ) case _: - raise ValueError(f"Unsupported action: {action}") - - @classmethod - def _build_response( - cls, - *, - state: AccountStepByStepTourState | None, - ) -> StepByStepTourStateResponse: - if state is None: - return { - "first_workspace_id": None, - "skipped": False, - "completed_task_ids": [], - "manually_enabled_workspace_ids": [], - "manually_disabled_workspace_ids": [], - "updated_at": None, - } - - return { - "first_workspace_id": state.first_workspace_id, - "skipped": state.skipped, - "completed_task_ids": cls._normalize_ids(state.completed_task_ids), - "manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids), - "manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids), - "updated_at": state.updated_at, - } + raise ValueError(f"Unsupported action: {patch.action}") @staticmethod - def _validate_task_id(task_id: str) -> None: - if task_id not in STEP_BY_STEP_TOUR_TASK_IDS: + def _require_workspace(context: RequestContext) -> str: + if context.active_workspace_id is None: + raise RuntimeError("Console account admission did not resolve an active workspace") + return context.active_workspace_id + + @staticmethod + def _require_task_id(task_id: str | None) -> str: + if task_id is None: + raise ValueError("task_id is required") + if task_id not in _TASK_IDS: raise ValueError(f"Unsupported task_id: {task_id}") + return task_id @classmethod - def _add_id(cls, values: list[str], value: str) -> list[str]: + def _add_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]: normalized = cls._normalize_ids(values) - if value in normalized: - return normalized - return [*normalized, value] + return normalized if value in normalized else (*normalized, value) @classmethod - def _remove_id(cls, values: list[str], value: str) -> list[str]: - return [item for item in cls._normalize_ids(values) if item != value] + def _remove_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]: + return tuple(item for item in cls._normalize_ids(values) if item != value) @staticmethod - def _normalize_ids(values: list[str]) -> list[str]: - normalized: list[str] = [] - for value in values: - if value not in normalized: - normalized.append(value) - return normalized + def _normalize_ids(values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(dict.fromkeys(values)) + + @staticmethod + def _to_result(state: StepByStepTourState | None) -> StepByStepTourResult: + if state is None: + return StepByStepTourResult() + return StepByStepTourResult( + first_workspace_id=state.first_workspace_id, + skipped=state.skipped, + completed_task_ids=tuple(dict.fromkeys(state.completed_task_ids)), + manually_enabled_workspace_ids=tuple(dict.fromkeys(state.manually_enabled_workspace_ids)), + manually_disabled_workspace_ids=tuple(dict.fromkeys(state.manually_disabled_workspace_ids)), + updated_at=state.updated_at, + ) diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py index 1c84b70b08e..33bbdcb2f69 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_members.py @@ -300,7 +300,7 @@ class TestOwnerTransferApiWithContainers: ) assert ( factory.get_join(db_session_with_containers, tenant=tenant, account=current_user).role - == TenantAccountRole.ADMIN + == TenantAccountRole.NORMAL ) mock_new_owner_email.assert_called_once() mock_old_owner_email.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py b/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py index 0ec399ba2b5..b35e01bdbfa 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py @@ -102,10 +102,8 @@ class TestConversationRenameApi: ConversationRenameApi().post(_completion_app(), _end_user(), uuid4()) @patch("controllers.web.conversation.ConversationService.rename") - @patch("controllers.web.conversation.web_ns") - def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None: + def test_rename_success(self, mock_rename: MagicMock, app: Flask) -> None: c_id = uuid4() - mock_ns.payload = {"name": "New Name", "auto_generate": False} conv = SimpleNamespace( id=str(c_id), name="New Name", @@ -126,10 +124,8 @@ class TestConversationRenameApi: "controllers.web.conversation.ConversationService.rename", side_effect=ConversationNotExistsError(), ) - @patch("controllers.web.conversation.web_ns") - def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None: + def test_rename_not_found(self, mock_rename: MagicMock, app: Flask) -> None: c_id = uuid4() - mock_ns.payload = {"name": "X", "auto_generate": False} with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "X"}): with pytest.raises(NotFound, match="Conversation Not Exists"): diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index b8d5bcf7668..db538d78c13 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -1671,7 +1671,7 @@ class TestTenantService: def test_update_member_role_to_owner(self, db_session_with_containers: Session, mock_external_service_dependencies): """ - Test updating member role to owner (should change current owner to admin). + Test updating member role to owner (should change current owner to normal). """ fake = Faker() tenant_name = fake.company() @@ -1723,7 +1723,7 @@ class TestTenantService: .filter_by(tenant_id=tenant.id, account_id=member_account.id) .first() ) - assert owner_join.role == "admin" + assert owner_join.role == "normal" assert member_join.role == "owner" def test_update_member_role_already_assigned( diff --git a/api/tests/unit_tests/clients/agent_backend/test_factory.py b/api/tests/unit_tests/clients/agent_backend/test_factory.py index 626bf5f1dfd..490c1bc6360 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_factory.py +++ b/api/tests/unit_tests/clients/agent_backend/test_factory.py @@ -9,6 +9,7 @@ from clients.agent_backend.factory import create_agent_backend_client, create_ag from configs import dify_config from services import agent_app_sandbox_service from services.agent import home_snapshot_service, workspace_service +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize( @@ -78,9 +79,12 @@ def test_default_agent_backend_clients_forward_authentication( module: ModuleType, extra_kwargs: dict[str, float], ) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent-backend") - monkeypatch.setattr(dify_config, "AGENT_BACKEND_API_TOKEN", "secret-token") - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS", 123.5) + apply_config_overrides( + monkeypatch, + AGENT_BACKEND_BASE_URL="http://agent-backend", + AGENT_BACKEND_API_TOKEN="secret-token", + AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=123.5, + ) create_client = MagicMock() monkeypatch.setattr(module, "create_agent_backend_client", create_client) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py index cff6695e414..9231e274d5c 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -238,6 +238,42 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat assert "| value | string
integer
number
boolean | | No |" in patched +def test_patch_union_schema_markdown_preserves_nullable_enum_values(tmp_path: Path): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-openapi.json" + spec_path.write_text( + json.dumps( + { + "components": { + "schemas": { + "StepByStepTourStatePatchPayload": { + "properties": { + "task_id": { + "anyOf": [ + {"enum": ["home", "studio"], "type": "string"}, + {"type": "null"}, + ], + }, + }, + }, + }, + } + } + ), + encoding="utf-8", + ) + markdown = """#### StepByStepTourStatePatchPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| task_id | string | Task ID | No | +""" + + patched = module._patch_union_schema_markdown(markdown, spec_path) + + assert '| task_id | string,
**Available values:** "home", "studio" | Task ID | No |' in patched + + def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path): module = _load_generate_swagger_markdown_docs_module() spec_path = tmp_path / "console-openapi.json" diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index fc1ae3f5904..26857efbe7b 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -9,6 +9,8 @@ from pathlib import Path from jsonschema import Draft202012Validator +from tests.unit_tests.config_override import apply_config_overrides + def _walk_values(value): yield value @@ -162,7 +164,7 @@ def test_apply_runtime_defaults_forces_swagger_routes_on(monkeypatch): from configs import dify_config monkeypatch.setenv("SWAGGER_UI_ENABLED", "false") - monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", False) + apply_config_overrides(monkeypatch, SWAGGER_UI_ENABLED=False) module.apply_runtime_defaults() diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py index 5e2f8ffaa09..d8c47e2747e 100644 --- a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py +++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py @@ -21,6 +21,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from models import Tenant from models.provider import Provider, ProviderModel, ProviderType from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider +from tests.unit_tests.config_override import apply_config_overrides def _invoke_reset() -> int: @@ -88,7 +89,7 @@ def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) - def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): - monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) exit_code = _invoke_reset() captured = capsys.readouterr() @@ -107,7 +108,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( ) -> None: """The command must purge LLM provider rows AND every tool provider table that stores ciphertext encrypted under the tenant key (#35396).""" - monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") _bind_command_to_sqlite(monkeypatch, sqlite_session) @@ -147,7 +148,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( ) def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: """Multi-tenant deployments must purge every tenant, not just the first.""" - monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") _bind_command_to_sqlite(monkeypatch, sqlite_session) diff --git a/api/tests/unit_tests/config_override.py b/api/tests/unit_tests/config_override.py new file mode 100644 index 00000000000..9a65679fcc7 --- /dev/null +++ b/api/tests/unit_tests/config_override.py @@ -0,0 +1,26 @@ +"""Typed config override support shared by unit-test fixtures and helpers.""" + +from collections.abc import Generator +from contextlib import contextmanager + +import pytest + +from configs import dify_config + + +def apply_config_overrides(monkeypatch: pytest.MonkeyPatch, **values: object) -> None: + """Override known DifyConfig fields for the lifetime of ``monkeypatch``.""" + unknown_fields = values.keys() - type(dify_config).model_fields.keys() + if unknown_fields: + raise ValueError(f"Unknown DifyConfig fields: {sorted(unknown_fields)}") + + for name, value in values.items(): + monkeypatch.setattr(dify_config, name, value) + + +@contextmanager +def config_overrides_context(**values: object) -> Generator[None]: + """Apply validated config overrides as a context manager or decorator.""" + with pytest.MonkeyPatch.context() as monkeypatch: + apply_config_overrides(monkeypatch, **values) + yield diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index ce90f8f380a..9ca5f9774e6 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -41,6 +41,7 @@ import core.db.session_factory as session_factory_module from extensions import ext_redis from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase +from tests.unit_tests.config_override import apply_config_overrides def _patch_redis_clients_on_loaded_modules() -> None: @@ -99,17 +100,9 @@ def reset_redis_mock() -> None: @pytest.fixture(autouse=True) -def reset_secret_key() -> Iterator[None]: +def reset_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure SECRET_KEY-dependent logic sees an empty config value by default.""" - - from configs import dify_config - - original = dify_config.SECRET_KEY - dify_config.SECRET_KEY = "" - try: - yield - finally: - dify_config.SECRET_KEY = original + apply_config_overrides(monkeypatch, SECRET_KEY="") @pytest.fixture @@ -120,14 +113,9 @@ def config_overrides(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]: field names keeps tests scoped without replacing that instance with an unconstrained mock. ``monkeypatch`` restores every value after the test. """ - from configs import dify_config def apply(**values: object) -> None: - unknown_fields = values.keys() - type(dify_config).model_fields.keys() - if unknown_fields: - raise ValueError(f"Unknown DifyConfig fields: {sorted(unknown_fields)}") - for name, value in values.items(): - monkeypatch.setattr(dify_config, name, value) + apply_config_overrides(monkeypatch, **values) return apply diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 4f8a7caffec..46c5f6ea971 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from datetime import datetime from inspect import getclosurevars, getsource, unwrap from types import SimpleNamespace @@ -56,7 +57,7 @@ from controllers.console.agent.roster import ( from controllers.console.app import completion as completion_controller from controllers.console.app import message as message_controller from controllers.console.app.completion import AgentBuildChatFinalizeApi, AgentChatMessageApi, AgentChatMessageStopApi -from controllers.console.app.error import CompletionRequestError +from controllers.console.app.error import AgentSessionConfigurationChangedError, CompletionRequestError from controllers.console.app.message import ( AgentChatMessageListApi, AgentMessageApi, @@ -75,6 +76,7 @@ from services.entities.agent_entities import ( WorkflowAgentComposerQuery, WorkflowComposerCopyFromRosterPayload, ) +from tests.unit_tests.config_override import apply_config_overrides def _rbac_decorators(method: object) -> list[dict[str, object]]: @@ -395,7 +397,10 @@ def account_id() -> str: def test_agent_app_list_and_create_use_agent_route( - app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + sqlite_session: Session, ) -> None: captured: dict[str, object] = {} monkeypatch.setattr(roster_controller.dify_config, "RBAC_ENABLED", True) @@ -484,7 +489,9 @@ def test_agent_app_list_and_create_use_agent_route( lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, ) monkeypatch.setattr( - roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 + roster_controller.AgentRosterService, + "count_agent_app_debug_conversation_messages", + lambda _self, **kwargs: 0, ) monkeypatch.setattr( roster_controller.enterprise_rbac_service.RBACService.AgentPermissions, @@ -561,12 +568,22 @@ def test_agent_app_list_and_create_use_agent_route( assert count_params.agent_is_published is True with app.test_request_context( "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, + json={ + "name": "Iris", + "description": "Agent app", + "role": "Coordinator", + "icon_type": "emoji", + "icon": "robot", + }, ): created, status = unwrap(AgentAppListApi.post)( AgentAppListApi(), AgentAppCreatePayload( - name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot" + name="Iris", + description="Agent app", + role="Coordinator", + icon_type="emoji", + icon="robot", ), sqlite_session, "tenant-1", @@ -596,6 +613,76 @@ def test_agent_app_list_and_create_use_agent_route( } +def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + sqlite_session: Session, + config_overrides: Callable[..., None], +) -> None: + replace_whitelist = MagicMock() + initialize_access = MagicMock() + + class FakeAppService: + def get_app(self, app_obj: object, *, session: object) -> object: + return app_obj + + def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object: + return _app_detail_obj(id="app-created", bound_agent_id="agent-created") + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: Agent( + id="agent-created", + app_id="app-created", + backing_app_id=None, + role="Created role", + active_config_snapshot_id=None, + ), + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_build_conversation", + lambda _self, **kwargs: "debug-conversation-created", + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + config_overrides(RBAC_ENABLED=False) + monkeypatch.setattr( + roster_controller.enterprise_rbac_service.RBACService.AppAccess, + "replace_whitelist", + replace_whitelist, + ) + monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access) + + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"}, + ): + created, status = unwrap(AgentAppListApi.post)( + AgentAppListApi(), + AgentAppCreatePayload( + name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot" + ), + sqlite_session, + "tenant-1", + _account(account_id=account_id), + ) + + assert status == 201 + assert created["id"] == "agent-created" + replace_whitelist.assert_not_called() + initialize_access.assert_not_called() + + def test_agent_app_create_payload_allows_optional_role() -> None: omitted = roster_controller.AgentAppCreatePayload.model_validate( {"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"} @@ -1009,7 +1096,7 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model) monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 2) monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True) - monkeypatch.setattr("models.model.dify_config.SERVICE_API_URL", "https://api.example.test/v1") + apply_config_overrides(monkeypatch, SERVICE_API_URL="https://api.example.test/v1") response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id) assert response == { "access_ready": True, @@ -1777,6 +1864,38 @@ def test_agent_chat_stream_preflight_raises_first_error_event() -> None: assert stream.closed is True +def test_agent_chat_stream_preflight_preserves_session_configuration_error() -> None: + class ClosableStream: + def __init__(self) -> None: + self.closed = False + self._chunks = iter( + [ + "event: ping\n\n", + ( + 'data: {"event":"error","message":"Start a new conversation to continue.",' + '"code":"agent_session_configuration_changed","status":409}\n\n' + ), + ] + ) + + def __iter__(self): + return self + + def __next__(self) -> str: + return next(self._chunks) + + def close(self) -> None: + self.closed = True + + stream = ClosableStream() + with pytest.raises(AgentSessionConfigurationChangedError) as exc_info: + completion_controller._raise_agent_stream_error_before_response(stream) + assert exc_info.value.code == 409 + assert exc_info.value.error_code == "agent_session_configuration_changed" + assert "Start a new conversation" in exc_info.value.description + assert stream.closed is True + + def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None: stream = iter( ["event: ping\n\n", 'data: {"event":"message","answer":"hello"}\n\n', 'data: {"event":"message_end"}\n\n'] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py b/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py index a261f4c998b..707b11a3f97 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py @@ -10,6 +10,7 @@ from core.rbac import RBACPermission, RBACResourceScope from models import Account from models.agent import Agent, AgentScope, AgentSource, AgentStatus from models.model import App, AppMode +from tests.unit_tests.config_override import config_overrides_context TENANT_ID = "tenant-1" @@ -58,7 +59,7 @@ def _persist_app( def _patch_guard(account: Account, rbac_enabled: bool): return ( patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(account, TENANT_ID)), - patch("controllers.console.app.wraps.dify_config.RBAC_ENABLED", rbac_enabled), + config_overrides_context(RBAC_ENABLED=rbac_enabled), ) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_apis.py b/api/tests/unit_tests/controllers/console/app/test_app_apis.py index c7f8286fac5..6d574657017 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_apis.py @@ -77,6 +77,7 @@ from services.app_site_service import ( AppSiteCommandResult, AppSiteNotFoundError, ) +from tests.unit_tests.config_override import apply_config_overrides APP_ID = "11111111-1111-1111-1111-111111111111" TENANT_ID = "22222222-2222-2222-2222-222222222222" @@ -261,8 +262,11 @@ class TestAppEndpoints: oauth_server.issue_authorization_code.return_value = MagicMock(code="oauth-code-1") services = MagicMock(oauth_server=oauth_server) - monkeypatch.setattr(app_module.dify_config, "CREATORS_PLATFORM_FEATURES_ENABLED", True) - monkeypatch.setattr(app_module.dify_config, "CREATORS_PLATFORM_OAUTH_CLIENT_ID", "client-1") + apply_config_overrides( + monkeypatch, + CREATORS_PLATFORM_FEATURES_ENABLED=True, + CREATORS_PLATFORM_OAUTH_CLIENT_ID="client-1", + ) monkeypatch.setattr(app_module, "application_services", lambda: services) monkeypatch.setattr(app_module.AppDslService, "export_dsl", MagicMock(return_value="app: demo")) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 5a08cad43bf..16c2ae1007d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -21,6 +21,7 @@ from models.model import App, AppMode from services.app_dsl_service import ImportStatus from services.entities.dsl_entities import CheckDependenciesResult from services.entities.feature_entities import SystemFeatureModel, WebAppAuthModel +from tests.unit_tests.config_override import apply_config_overrides def _unwrap(func): @@ -240,7 +241,7 @@ class TestAppImportApi: "current_account_with_tenant", lambda: (_make_account(), "tenant-1"), ) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="import_app", @@ -276,7 +277,7 @@ class TestAppImportApi: "current_account_with_tenant", lambda: (_make_account(), "tenant-1"), ) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="import_app", @@ -353,7 +354,7 @@ class TestAppImportConfirmApi: ) ) monkeypatch.setattr(app_import_module.redis_client, "get", redis_get) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="confirm_import", @@ -397,7 +398,7 @@ class TestAppImportConfirmApi: b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}' ), ) - monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) app_id = _install_persisting_service_result( monkeypatch, method_name="confirm_import", diff --git a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py index 075997b40f9..5e5762f9260 100644 --- a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py +++ b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py @@ -21,6 +21,7 @@ from models import Account from models.account import AccountStatus from models.enums import AppMCPServerStatus from models.model import App, AppMCPServer, AppMode, IconType +from tests.unit_tests.config_override import config_overrides_context def _app( @@ -309,7 +310,7 @@ class TestAppMCPServerRefreshController: current_user = Account(name="Current user", email="user@example.com", status=AccountStatus.ACTIVE) current_user.id = "account-1" with ( - patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-1"), diff --git a/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py b/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py index 08b3799de34..1c1ee83e19a 100644 --- a/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py @@ -18,6 +18,7 @@ from libs import login as login_lib from models import Tenant from models.account import Account, AccountStatus, TenantAccountRole from models.model import App, AppMode, IconType +from tests.unit_tests.config_override import apply_config_overrides def _make_account(role: TenantAccountRole) -> Account: @@ -55,9 +56,12 @@ def _patch_console_guards( *, rbac_enabled: bool = False, ) -> None: - monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) - monkeypatch.setattr(login_lib.dify_config, "RBAC_ENABLED", rbac_enabled) - monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides( + monkeypatch, + LOGIN_DISABLED=True, + RBAC_ENABLED=rbac_enabled, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + ) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index 5ef2a3a832f..5a5bf81336d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -22,6 +22,7 @@ from core.workflow.llm_environment_variable import LLMEnvironmentVariable from graphon.file import File, FileTransferMethod, FileType from graphon.variables import SecretVariable, StringVariable from graphon.variables.variables import RAGPipelineVariable +from tests.unit_tests.config_override import apply_config_overrides def _make_workflow(**overrides): @@ -77,6 +78,9 @@ def _make_workflow(**overrides): ) for key, value in overrides.items(): setattr(workflow, key, value) + workflow.get_created_by_account = Mock(return_value=workflow.created_by_account) + workflow.get_updated_by_account = Mock(return_value=workflow.updated_by_account) + workflow.get_tool_published = Mock(return_value=workflow.tool_published) return workflow @@ -616,6 +620,25 @@ def test_draft_workflow_get_serializes_response_model(monkeypatch: pytest.Monkey ] +def test_published_workflow_get_uses_session_aware_response_source(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow() + session = Mock(spec=Session) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(session=Mock(return_value=session))) + monkeypatch.setattr( + workflow_module, "WorkflowService", lambda: SimpleNamespace(get_published_workflow=lambda **_kwargs: workflow) + ) + + api = workflow_module.PublishedWorkflowApi() + handler = inspect.unwrap(api.get) + + response = handler(api, app_model=SimpleNamespace(id="app")) + + assert response["id"] == "workflow-1" + workflow.get_created_by_account.assert_called_once_with(session=session) + workflow.get_updated_by_account.assert_called_once_with(session=session) + workflow.get_tool_published.assert_called_once_with(session=session) + + def test_pipeline_variable_response_accepts_legacy_file_field_names() -> None: response = workflow_module.PipelineVariableResponse.model_validate( { @@ -875,7 +898,7 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp access_filter = SimpleNamespace(is_app_accessible=lambda app_id, _maintainer, _account_id: app_id == app_id_1) resolve_access = Mock(return_value=access_filter) monkeypatch.setattr(workflow_module, "resolve_app_access_filter", resolve_access) - monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(workflow_module.file_helpers, "get_signed_file_url", sign_avatar) short_session = Mock() monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(short_session)) @@ -966,7 +989,7 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte "WorkflowService", lambda: SimpleNamespace(get_tenant_app_maintainers=lambda app_ids, tenant_id, session: dict.fromkeys(app_ids)), ) - monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(Mock())) first_pipeline = Mock() diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py index 56049936f27..337a4f31881 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py @@ -18,6 +18,7 @@ from libs import login as login_lib from models import App, Tenant, WorkflowComment, WorkflowCommentMention, WorkflowCommentReply from models.account import Account, AccountStatus, TenantAccountRole from models.model import AppMode, IconType +from tests.unit_tests.config_override import apply_config_overrides JAN_1_2024_NOON = datetime(2024, 1, 1, 12, 0, 0) JAN_1_2024_NOON_TS = int(JAN_1_2024_NOON.timestamp()) @@ -58,12 +59,11 @@ def _make_app() -> App: def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None: - monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) - monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py index b825d379f82..f223228048d 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py @@ -15,6 +15,7 @@ from libs import login as login_lib from models import App, Tenant from models.account import Account, AccountStatus, TenantAccountRole from models.model import AppMode, IconType +from tests.unit_tests.config_override import apply_config_overrides def _make_account() -> Account: @@ -47,14 +48,17 @@ def _make_app(mode: AppMode) -> App: def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None: # Skip setup and auth guardrails - monkeypatch.setattr("configs.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + LOGIN_DISABLED=True, + INIT_PASSWORD="", + ) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) - monkeypatch.setattr(console_wraps.dify_config, "INIT_PASSWORD", "") # Avoid hitting the database when resolving the app model monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index 88aed318fb2..a1be8254d63 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -20,6 +20,7 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowPause, WorkflowRun, WorkflowType +from tests.unit_tests.config_override import apply_config_overrides @dataclass(frozen=True) @@ -77,7 +78,7 @@ class _PauseEntity: def test_pause_details_returns_backstage_input_url( app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: - monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com") + apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com") tenant_id = str(uuid4()) run_id = str(uuid4()) @@ -137,7 +138,7 @@ def test_pause_details_returns_backstage_input_url( def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: - monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com") + apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com") run_id = str(uuid4()) _persist_run( diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index 37e67cf6fc5..64e2813f62f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -12,6 +12,7 @@ from controllers.console.auth.error import AuthenticationFailedError from controllers.console.auth.login import LoginApi from enums import DeploymentEdition from models.account import Account +from tests.unit_tests.config_override import config_overrides_context def encode_password(password: str) -> str: @@ -35,7 +36,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invalid_email_with_registration_allowed( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db @@ -67,7 +68,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_wrong_password_returns_error( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_db @@ -99,7 +100,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invalid_email_with_registration_disabled( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db diff --git a/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py index 1f6cd681a75..72d28c0385b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py @@ -19,6 +19,7 @@ from services.data_source_oauth_service import ( InvalidDataSourceOAuthProviderError, ) from services.entities.data_source_oauth_entities import DataSourceOAuthCallback +from tests.unit_tests.config_override import config_overrides_context def _request_context() -> RequestContext: @@ -72,10 +73,7 @@ def test_callback_parses_query_and_returns_flask_redirect() -> None: with ( app.test_request_context("/?code=code-1"), - patch( - "controllers.console.auth.data_source_oauth.dify_config.CONSOLE_WEB_URL", - "https://console.example/root?lang=en#top", - ), + config_overrides_context(CONSOLE_WEB_URL="https://console.example/root?lang=en#top"), patch( "controllers.console.auth.data_source_oauth.application_services", return_value=_services(service), diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register.py b/api/tests/unit_tests/controllers/console/auth/test_email_register.py index 4f1bec336e5..7b5f859877b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register.py @@ -1,30 +1,57 @@ -"""Unit tests for email register controller endpoints.""" +"""Unit tests for the email-registration Flask adapter.""" from __future__ import annotations -from collections.abc import Callable -from unittest.mock import MagicMock, patch +from collections.abc import Callable, Generator +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest from flask import Flask +from pydantic import ValidationError +from controllers.console import bp as console_bp from controllers.console.auth.email_register import ( EmailRegisterCheckApi, EmailRegisterResetApi, + EmailRegisterResetPayload, EmailRegisterSendEmailApi, ) -from controllers.console.auth.error import NormalizedEmailAlreadyInUseError -from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError +from controllers.console.auth.error import ( + EmailAlreadyInUseError, + EmailCodeError, + EmailRegisterLimitError, + EmailRegisterRateLimitExceededError, + InvalidEmailError, + InvalidTokenError, + NormalizedEmailAlreadyInUseError, + PasswordMismatchError, +) +from controllers.console.error import ( + AccountInFreezeError, + EmailDomainSuspendedError, + EmailSendIpLimitError, + SeatsLimitExceeded, +) from enums import DeploymentEdition -from models.account import Account -from services.entities.feature_entities import SystemFeatureModel -from services.errors.account import ( +from services.account_email_registration_service import AccountEmailRegistrationService +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + AccountEmailFrozenError, AccountNormalizedEmailAlreadyInUseError, - AccountRegisterError, -) -from services.errors.account import ( - EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, + EmailRegistrationPasswordMismatchError, + EmailRegistrationSeatsLimitError, + EmailRegistrationSendIPLimitedError, + EmailRegistrationSendRateLimitError, + EmailRegistrationVerificationLimitError, + InvalidEmailRegistrationAddressError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, ) +from services.entities.account_entities import AccountEmailRegistrationVerification, AccountSessionTokens +from services.entities.feature_entities import SystemFeatureModel @pytest.fixture(autouse=True) @@ -32,6 +59,33 @@ def _cloud_edition(config_overrides: Callable[..., None]) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) +@contextmanager +def _request( + app: Flask, + service: Mock, + *, + path: str, + payload: dict[str, str], +) -> Generator[None, None, None]: + services = SimpleNamespace(accounts=SimpleNamespace(email_registration=service)) + features = SystemFeatureModel( + deployment_edition=DeploymentEdition.CLOUD, + enable_email_password_login=True, + is_allow_register=True, + ) + with ( + patch("controllers.console.auth.email_register.application_services", return_value=services), + patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features), + patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1"), + app.test_request_context(path, method="POST", json=payload), + ): + yield + + +def _service() -> Mock: + return Mock(spec=AccountEmailRegistrationService) + + def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None: error = NormalizedEmailAlreadyInUseError() @@ -40,323 +94,210 @@ def test_normalized_email_conflict_exposes_a_distinct_error_code() -> None: assert error.data["code"] == "normalized_email_already_in_use" -class TestEmailRegisterSendEmailApi: - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.send_email_register_email") - @patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type") - @patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False) - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_send_email_normalizes_and_falls_back( - self, - mock_extract_ip, - mock_is_email_send_ip_limit, - mock_is_freeze, - mock_send_mail, - mock_get_account, - app: Flask, +def test_send_email_delegates_with_remote_ip(app: Flask) -> None: + service = _service() + service.send_code.return_value = "token-123" + + with _request( + app, + service, + path="/email-register/send-email", + payload={"email": "Invitee@Example.com", "language": "zh-Hans"}, ): - mock_send_mail.return_value = "token-123" - mock_is_freeze.return_value = False - account = Account(name="Invitee", email="invitee@example.com") - mock_get_account.return_value = account + response = EmailRegisterSendEmailApi().post() - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register/send-email", - method="POST", - json={"email": "Invitee@Example.com", "language": "en-US"}, - ): - response = EmailRegisterSendEmailApi().post() + assert response == {"result": "success", "data": "token-123"} + assert service.send_code.call_args.kwargs == { + "remote_ip": "127.0.0.1", + "requested_email": "Invitee@Example.com", + "requested_language": "zh-Hans", + } - assert response == {"result": "success", "data": "token-123"} - mock_is_freeze.assert_called_once_with("invitee@example.com") - mock_send_mail.assert_called_once_with(email="invitee@example.com", account=account, language="en-US") - mock_extract_ip.assert_called_once() - mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1") - @pytest.mark.parametrize( - ("freeze_type", "expected_error"), - [ - ("freeze", AccountInFreezeError), - ("email_domain_suspended", EmailDomainSuspendedError), - ], +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(EmailRegistrationSendIPLimitedError(), EmailSendIpLimitError, id="ip-limit"), + pytest.param(EmailRegistrationSendRateLimitError(1), EmailRegisterRateLimitExceededError, id="send-limit"), + pytest.param(AccountEmailFrozenError(), AccountInFreezeError, id="frozen"), + pytest.param(AccountEmailDomainSuspendedError(), EmailDomainSuspendedError, id="suspended-domain"), + ], +) +def test_send_email_translates_application_errors( + app: Flask, + service_error: Exception, + http_error: type[Exception], +) -> None: + service = _service() + service.send_code.side_effect = service_error + + with _request( + app, + service, + path="/email-register/send-email", + payload={"email": "invitee@example.com"}, + ): + with pytest.raises(http_error): + EmailRegisterSendEmailApi().post() + + +def test_verify_email_code_serializes_application_result(app: Flask) -> None: + service = _service() + service.verify_code.return_value = AccountEmailRegistrationVerification( + email="user@example.com", + token="verified-token", ) - @patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type") - @patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False) - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_send_email_rejects_frozen_email( - self, - mock_extract_ip, - mock_is_email_send_ip_limit, - mock_get_freeze_type, - app: Flask, - freeze_type, - expected_error, + + with _request( + app, + service, + path="/email-register/validity", + payload={"email": "User@Example.com", "code": "123456", "token": "pending-token"}, ): - mock_get_freeze_type.return_value = freeze_type - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) + response = EmailRegisterCheckApi().post() - with ( - patch("controllers.console.auth.email_register.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register/send-email", - method="POST", - json={"email": "Invitee@Example.com"}, - ): - with pytest.raises(expected_error): - EmailRegisterSendEmailApi().post() - - mock_get_freeze_type.assert_called_once_with("invitee@example.com") - mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1") - mock_extract_ip.assert_called_once() - - -class TestEmailRegisterCheckApi: - @patch("controllers.console.auth.email_register.AccountService.reset_email_register_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.generate_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.add_email_register_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.AccountService.is_email_register_error_rate_limit") - def test_validity_normalizes_email_before_checks( - self, - mock_rate_limit_check, - mock_get_data, - mock_add_rate, - mock_revoke, - mock_generate_token, - mock_reset_rate, - app: Flask, - ): - mock_rate_limit_check.return_value = False - 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, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register/validity", - method="POST", - json={"email": "User@Example.com", "code": "4321", "token": "token-123"}, - ): - response = EmailRegisterCheckApi().post() - - assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"} - mock_rate_limit_check.assert_called_once_with("user@example.com") - mock_generate_token.assert_called_once_with( - "user@example.com", code="4321", additional_data={"phase": "register"} - ) - mock_reset_rate.assert_called_once_with("user@example.com") - mock_add_rate.assert_not_called() - mock_revoke.assert_called_once_with("token-123") - - -class TestEmailRegisterResetApi: - @pytest.mark.parametrize( - ("service_error", "expected_error"), - [ - (EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError), - (AccountNormalizedEmailAlreadyInUseError(), NormalizedEmailAlreadyInUseError), - (AccountRegisterError("frozen"), AccountInFreezeError), - ], + assert response == {"is_valid": True, "email": "user@example.com", "token": "verified-token"} + service.verify_code.assert_called_once_with( + email="User@Example.com", + code="123456", + token="pending-token", ) - @patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant") - def test_create_new_account_translates_freeze_errors( - self, - mock_create_account, - service_error, - expected_error, + + +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(EmailRegistrationVerificationLimitError(), EmailRegisterLimitError, id="attempt-limit"), + pytest.param(InvalidEmailRegistrationTokenError(), InvalidTokenError, id="token"), + pytest.param(InvalidEmailRegistrationAddressError(), InvalidEmailError, id="email"), + pytest.param(InvalidEmailRegistrationCodeError(), EmailCodeError, id="code"), + ], +) +def test_verify_email_code_translates_application_errors( + app: Flask, + service_error: Exception, + http_error: type[Exception], +) -> None: + service = _service() + service.verify_code.side_effect = service_error + + with _request( + app, + service, + path="/email-register/validity", + payload={"email": "user@example.com", "code": "wrong", "token": "pending-token"}, ): - mock_create_account.side_effect = service_error + with pytest.raises(http_error): + EmailRegisterCheckApi().post() - with pytest.raises(expected_error): - EmailRegisterResetApi()._create_new_account( - email="user@example.com", - password="ValidPass123!", - ) - @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.login") - @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_reset_creates_account_with_normalized_email( - self, - mock_extract_ip, - mock_get_data, - mock_revoke_token, - mock_get_account, - mock_create_account, - mock_login, - mock_reset_login_rate, - app: Flask, +def test_register_delegates_and_serializes_tokens(app: Flask) -> None: + service = _service() + service.register.return_value = AccountSessionTokens( + access_token="access", + refresh_token="refresh", + csrf_token="csrf", + ) + + with _request( + app, + service, + path="/email-register", + payload={ + "token": "verified-token", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "language": "zh-Hans", + "timezone": "Asia/Shanghai", + }, ): - mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} - mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com") - token_pair = MagicMock() - token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} - mock_login.return_value = token_pair - mock_get_account.return_value = None + response = EmailRegisterResetApi().post() - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register", - method="POST", - json={"token": "token-123", "new_password": "ValidPass123!", "password_confirm": "ValidPass123!"}, - ): - response = EmailRegisterResetApi().post() + assert response == { + "result": "success", + "data": {"access_token": "access", "refresh_token": "refresh", "csrf_token": "csrf"}, + } + assert service.register.call_args.kwargs == { + "remote_ip": "127.0.0.1", + "token": "verified-token", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "language": "zh-Hans", + "timezone": "Asia/Shanghai", + } - assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with( - email="invitee@example.com", - password="ValidPass123!", - timezone=None, - language=None, - ip_address="127.0.0.1", - ) - mock_reset_login_rate.assert_called_once_with("invitee@example.com") - mock_revoke_token.assert_called_once_with("token-123") - mock_extract_ip.assert_called_once() - @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.login") - @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_reset_passes_timezone_to_new_account( - self, - mock_extract_ip, - mock_get_data, - mock_revoke_token, - mock_get_account, - mock_create_account, - mock_login, - mock_reset_login_rate, - app: Flask, +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(EmailRegistrationPasswordMismatchError(), PasswordMismatchError, id="password"), + pytest.param(InvalidEmailRegistrationTokenError(), InvalidTokenError, id="token"), + pytest.param( + AccountNormalizedEmailAlreadyInUseError(), + NormalizedEmailAlreadyInUseError, + id="normalized-email-in-use", + ), + pytest.param(AccountEmailAlreadyInUseError(), EmailAlreadyInUseError, id="email-in-use"), + pytest.param(EmailRegistrationSeatsLimitError(), SeatsLimitExceeded, id="seat-limit"), + pytest.param(AccountEmailFrozenError(), AccountInFreezeError, id="frozen"), + pytest.param(AccountEmailDomainSuspendedError(), EmailDomainSuspendedError, id="suspended-domain"), + ], +) +def test_register_translates_application_errors( + app: Flask, + service_error: Exception, + http_error: type[Exception], +) -> None: + service = _service() + service.register.side_effect = service_error + + with _request( + app, + service, + path="/email-register", + payload={ + "token": "verified-token", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + }, ): - mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} - mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com") - token_pair = MagicMock() - token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} - mock_login.return_value = token_pair - mock_get_account.return_value = None + with pytest.raises(http_error): + EmailRegisterResetApi().post() - feature_flags = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, - is_allow_register=True, + +def test_reset_payload_rejects_invalid_timezone() -> None: + with pytest.raises(ValidationError): + EmailRegisterResetPayload.model_validate( + { + "token": "token-123", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "timezone": "", + } ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register", - method="POST", - json={ - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - "timezone": "Asia/Shanghai", - }, - ): - response = EmailRegisterResetApi().post() - assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with( - email="invitee@example.com", - password="ValidPass123!", - timezone="Asia/Shanghai", - language=None, - ip_address="127.0.0.1", + +def test_invalid_password_is_sanitized_by_real_error_handler(caplog: pytest.LogCaptureFixture) -> None: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(console_bp) + features = SystemFeatureModel( + deployment_edition=DeploymentEdition.CLOUD, + enable_email_password_login=True, + is_allow_register=True, + ) + password_marker = "SecretMarker" + + with patch("controllers.console.flask_admission.FeatureService.get_system_features", return_value=features): + response = app.test_client().post( + "/console/api/email-register", + json={ + "token": "verified-token", + "new_password": password_marker, + "password_confirm": password_marker, + }, ) - mock_reset_login_rate.assert_called_once_with("invitee@example.com") - mock_revoke_token.assert_called_once_with("token-123") - mock_extract_ip.assert_called_once() - @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") - @patch("controllers.console.auth.email_register.AccountService.login") - @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") - @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") - @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") - @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") - def test_reset_passes_language_to_new_account( - self, - mock_extract_ip, - mock_get_data, - mock_revoke_token, - mock_get_account, - mock_create_account, - mock_login, - mock_reset_login_rate, - app: Flask, - ): - mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} - mock_create_account.return_value = Account(name="Invitee", email="invitee@example.com") - token_pair = MagicMock() - token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} - 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, - ) - with ( - patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), - ): - with app.test_request_context( - "/email-register", - method="POST", - json={ - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - "language": "zh-Hans", - }, - ): - response = EmailRegisterResetApi().post() - - assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with( - email="invitee@example.com", - password="ValidPass123!", - timezone=None, - language="zh-Hans", - ip_address="127.0.0.1", - ) - mock_reset_login_rate.assert_called_once_with("invitee@example.com") - mock_revoke_token.assert_called_once_with("token-123") - mock_extract_ip.assert_called_once() + assert response.status_code == 422 + assert password_marker not in response.get_data(as_text=True) + assert password_marker not in caplog.text diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py b/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py deleted file mode 100644 index e8331bda8cc..00000000000 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py +++ /dev/null @@ -1,44 +0,0 @@ -from unittest.mock import ANY, patch - -import pytest -from pydantic import ValidationError - -from controllers.console.auth.email_register import EmailRegisterResetApi, EmailRegisterResetPayload -from models.account import Account - - -@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant") -def test_create_new_account_uses_requested_language(mock_create_account): - account = Account(name="Invitee", email="invitee@example.com") - mock_create_account.return_value = account - - result = EmailRegisterResetApi()._create_new_account( - "invitee@example.com", - "ValidPass123!", - timezone="Asia/Shanghai", - language="zh-Hans", - ) - - assert result is account - mock_create_account.assert_called_once_with( - email="invitee@example.com", - name="invitee@example.com", - password="ValidPass123!", - interface_language="zh-Hans", - timezone="Asia/Shanghai", - ip_address=None, - check_normalized_email=True, - session=ANY, - ) - - -def test_reset_payload_rejects_invalid_timezone(): - with pytest.raises(ValidationError): - EmailRegisterResetPayload.model_validate( - { - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - "timezone": "", - } - ) diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index 75ed9f5b6af..930ac17279b 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -461,14 +461,17 @@ class TestEmailCodeLoginApi: mock_verify_challenge, mock_db, app: Flask, + config_overrides: Callable[..., None], ): + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=True, + ) mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( status=EmailCodeLoginChallengeStatus.INVALID_TOKEN ) with ( - patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True), app.test_request_context( "/email-code-login/validity", method="POST", @@ -502,10 +505,13 @@ class TestEmailCodeLoginApi: mock_verify_challenge, mock_db, app: Flask, + config_overrides: Callable[..., None], ): + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=True, + ) with ( - patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True), app.test_request_context( "/email-code-login/validity", method="POST", @@ -527,14 +533,17 @@ class TestEmailCodeLoginApi: mock_verify_challenge, mock_db, app: Flask, + config_overrides: Callable[..., None], ): + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=False, + ) mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( status=EmailCodeLoginChallengeStatus.INVALID_TOKEN ) with ( - patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", False), app.test_request_context( "/email-code-login/validity", method="POST", diff --git a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py index 783aac0a5df..b9eda4eeb0a 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py @@ -17,6 +17,7 @@ from enums import DeploymentEdition from models.account import Account from models.engine import db from services.entities.feature_entities import SystemFeatureModel +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -61,7 +62,7 @@ class TestForgotPasswordSendEmailApi: "controllers.console.auth.forgot_password.FeatureService.get_system_features", return_value=controller_features, ), - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with app.test_request_context( @@ -108,7 +109,7 @@ class TestForgotPasswordCheckApi: enable_email_password_login=True, ) with ( - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with app.test_request_context( @@ -154,7 +155,7 @@ class TestForgotPasswordResetApi: enable_email_password_login=True, ) with ( - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with database_app.test_request_context( diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py index 969e84505aa..1d95860e1b4 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -22,6 +22,7 @@ from services.errors.account import AccountRegisterError from services.errors.account import ( EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError, ) +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture(autouse=True) @@ -586,7 +587,7 @@ class TestAccountGeneration: ("freeze", AccountRegisterError), ], ) - @patch("controllers.console.auth.oauth.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) @patch("controllers.console.auth.oauth.BillingService.get_email_freeze_type") @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) @patch("controllers.console.auth.oauth.FeatureService") diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py index 59a12a9b329..e3910d4a348 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_redirect.py @@ -7,6 +7,7 @@ from flask import Flask from controllers.console.auth.oauth import OAuthCallback, OAuthLogin from libs.oauth import OAuthUserInfo, encode_oauth_state from models.account import Account, AccountStatus, Tenant +from tests.unit_tests.config_override import config_overrides_context REDIRECT_URL = "/apps?category=workflow" CONSOLE_WEB_URL = "https://console.example.com" @@ -74,7 +75,7 @@ def test_oauth_callback_validates_redirect_url_and_appends_new_user_flag( with ( patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), - patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + config_overrides_context(CONSOLE_WEB_URL=CONSOLE_WEB_URL), patch("controllers.console.auth.oauth._generate_account", return_value=(account, oauth_new_user)), patch("controllers.console.auth.oauth.TenantService.create_owner_tenant_if_not_exist"), patch("controllers.console.auth.oauth.AccountService.login", return_value=token_pair), @@ -109,7 +110,7 @@ def test_oauth_callback_with_invitation_establishes_console_session(app: Flask) with ( patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), - patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + config_overrides_context(CONSOLE_WEB_URL=CONSOLE_WEB_URL), patch("controllers.console.auth.oauth.RegisterService") as register_service, patch("controllers.console.auth.oauth.AccountService.link_account_integrate") as link_account, patch("controllers.console.auth.oauth.AccountService.login", return_value=token_pair) as login, @@ -155,7 +156,7 @@ def test_oauth_callback_with_invitation_rejects_another_account(app: Flask) -> N with ( patch("controllers.console.auth.oauth.get_oauth_providers", return_value={"google": oauth_provider}), - patch("controllers.console.auth.oauth.dify_config.CONSOLE_WEB_URL", CONSOLE_WEB_URL), + config_overrides_context(CONSOLE_WEB_URL=CONSOLE_WEB_URL), patch("controllers.console.auth.oauth.RegisterService") as register_service, patch("controllers.console.auth.oauth.AccountService.link_account_integrate") as link_account, patch("controllers.console.auth.oauth.AccountService.login") as login, diff --git a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py index c2375fab888..a94816949ac 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -26,6 +26,7 @@ from controllers.console.error import AccountNotFound, EmailSendIpLimitError from enums import DeploymentEdition from models.account import Account, Tenant, TenantAccountJoin from services.entities.feature_entities import SystemFeatureModel +from tests.unit_tests.config_override import apply_config_overrides SQLITE_MODELS = (Account, Tenant, TenantAccountJoin) @@ -46,7 +47,7 @@ def _bind_database_session(session: Session) -> Generator[scoped_session[Session def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: """Keep endpoint decorators deterministic without requiring the configured app database.""" - monkeypatch.setattr("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( "controllers.console.wraps.FeatureService.get_system_features", lambda: SystemFeatureModel( diff --git a/api/tests/unit_tests/controllers/console/billing/test_billing.py b/api/tests/unit_tests/controllers/console/billing/test_billing.py index ce26f4a8c6d..b69671a598d 100644 --- a/api/tests/unit_tests/controllers/console/billing/test_billing.py +++ b/api/tests/unit_tests/controllers/console/billing/test_billing.py @@ -24,6 +24,7 @@ from services.errors.billing import ( BillingUpstreamInvalidResponseError, BillingUpstreamUnavailableError, ) +from tests.unit_tests.config_override import config_overrides_context class TestBillingPortal: @@ -188,8 +189,7 @@ class TestPartnerTenants: console_wraps._is_setup_completed.reset_success() monkeypatch.setattr(console_wraps.db, "session", sqlite_session) with ( - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch("libs.login.dify_config.LOGIN_DISABLED", False), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, LOGIN_DISABLED=False), patch("libs.login.check_csrf_token") as mock_csrf, ): mock_csrf.return_value = None diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py index 91815612355..f3b0cb25953 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py @@ -27,6 +27,7 @@ from models.engine import db from services.entities.knowledge_entities.rag_pipeline_entities import PipelineTemplateInfoEntity from services.errors.account import NoPermissionError from services.errors.rag_pipeline import RagPipelineResourceNotFoundError +from tests.unit_tests.config_override import config_overrides_context def _template_item() -> dict[str, object]: @@ -376,7 +377,7 @@ class TestPublishCustomizedPipelineTemplateApi: dataset = object() with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(Pipeline, "retrieve_dataset", return_value=dataset), patch.object(module.DatasetService, "check_dataset_permission") as legacy_acl, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, @@ -409,7 +410,7 @@ class TestPublishCustomizedPipelineTemplateApi: dataset = object() with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(Pipeline, "retrieve_dataset", return_value=dataset), patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, ): @@ -425,7 +426,7 @@ class TestPublishCustomizedPipelineTemplateApi: payload = _payload() with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(Pipeline, "retrieve_dataset", return_value=object()), patch.object( module.RagPipelineService, @@ -446,7 +447,7 @@ class TestPublishCustomizedPipelineTemplateApi: payload = _payload() with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=dataset), patch.object(module.DatasetService, "check_dataset_permission") as check_permission, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, @@ -464,7 +465,7 @@ class TestPublishCustomizedPipelineTemplateApi: account.role = TenantAccountRole.NORMAL with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=object()), patch.object(module.DatasetService, "check_dataset_permission") as check_permission, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, @@ -482,7 +483,7 @@ class TestPublishCustomizedPipelineTemplateApi: account.role = TenantAccountRole.EDITOR with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=object()), patch.object( module.DatasetService, @@ -503,7 +504,7 @@ class TestPublishCustomizedPipelineTemplateApi: account.role = TenantAccountRole.EDITOR with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(Pipeline, "retrieve_dataset", return_value=None), patch.object(module.DatasetService, "check_dataset_permission") as check_permission, patch.object(module.RagPipelineService, "publish_customized_pipeline_template") as publish, diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index fdc9dfe54f7..f4a2e2c948f 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -34,6 +34,7 @@ from models.workflow import Workflow, WorkflowType from services.errors.llm import InvokeRateLimitError from services.errors.rag_pipeline import RagPipelineResourceNotFoundError from services.rag_pipeline.rag_pipeline import RagPipelineService +from tests.unit_tests.config_override import config_overrides_context DEFAULT_WORKFLOW_TENANT_ID = "00000000-0000-0000-0000-000000000001" DEFAULT_WORKFLOW_APP_ID = "00000000-0000-0000-0000-000000000002" @@ -259,7 +260,7 @@ def test_rag_pipeline_transform_rejects_read_only_member(sqlite_engine: Engine) session.add(_dataset()) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), pytest.raises(Forbidden), ): handler(api, session, DEFAULT_WORKFLOW_TENANT_ID, account, UUID(DEFAULT_DATASET_ID)) @@ -293,7 +294,7 @@ def test_rag_pipeline_transform_enforces_legacy_dataset_permission_before_servic session.add(_dataset(maintainer="00000000-0000-0000-0000-000000000099")) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module.RagPipelineTransformService, "transform_dataset") as transform_dataset, pytest.raises(Forbidden), ): @@ -315,7 +316,7 @@ def test_rag_pipeline_transform_passes_authorized_dataset_and_account_to_service session.add(dataset) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module.RagPipelineTransformService, "transform_dataset", return_value=expected) as transform, ): response = handler(api, session, DEFAULT_WORKFLOW_TENANT_ID, account, UUID(DEFAULT_DATASET_ID)) @@ -333,7 +334,7 @@ def test_rag_pipeline_transform_maps_missing_pipeline_to_not_found(sqlite_engine session.add(_dataset()) with ( - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object( module.RagPipelineTransformService, "transform_dataset", @@ -355,7 +356,7 @@ def test_rag_pipeline_transform_skips_legacy_acl_when_rbac_is_enabled(sqlite_eng session.add(_dataset(maintainer="00000000-0000-0000-0000-000000000099")) with ( - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module.RagPipelineTransformService, "transform_dataset", return_value=expected) as transform, ): response = handler(api, session, DEFAULT_WORKFLOW_TENANT_ID, account, UUID(DEFAULT_DATASET_ID)) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index ada7de0ce20..b87cdc3ecaa 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -8,6 +8,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock, call, patch import pytest from flask import Flask +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, Forbidden, NotFound import services @@ -44,7 +45,7 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from core.rag.retrieval.retrieval_methods import RetrievalMethod from extensions.storage.storage_type import StorageType from models.account import Account, TenantAccountRole -from models.dataset import Dataset, DatasetQuery, Document +from models.dataset import AppDatasetJoin, Dataset, DatasetPermission, DatasetQuery, Document, DocumentSegment from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus from models.model import ApiToken, App, AppMode, IconType, UploadFile from services.dataset_ref_service import DatasetRef @@ -170,7 +171,29 @@ def make_document_status(**overrides) -> Document: return Document(**base) -class TestDatasetList: +def make_document_segment(*, position: int, completed: bool) -> DocumentSegment: + return DocumentSegment( + tenant_id="tenant-1", + dataset_id="dataset-1", + document_id="doc-1", + position=position, + content=f"segment {position}", + word_count=2, + tokens=2, + created_by="account-1", + completed_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) if completed else None, + ) + + +class _UsesSQLiteSession: + session: Session + + @pytest.fixture(autouse=True) + def _inject_sqlite_session(self, sqlite_session: Session) -> None: + self.session = sqlite_session + + +class TestDatasetList(_UsesSQLiteSession): def _mock_user(self): user = make_account() return user @@ -185,7 +208,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 assert resp["total"] == 1 assert resp["data"][0]["embedding_available"] is True @@ -201,7 +224,7 @@ class TestDatasetList: method = unwrap(api.get) current_user = self._mock_user() dataset = make_dataset() - session = MagicMock() + session = self.session with app.test_request_context("/datasets"): with ( patch.object(DatasetService, "get_datasets", return_value=([dataset], 1)), @@ -222,7 +245,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets_by_ids", return_value=(datasets, 2)) as by_ids_mock, patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) by_ids_mock.assert_called_once() assert status == 200 assert resp["total"] == 2 @@ -251,7 +274,7 @@ class TestDatasetList: return_value=permissions, ) as get_permissions, ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) get_permissions.assert_called_once_with("tenant-1", current_user.id, session=ANY) assert status == 200 assert resp["data"][0]["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] @@ -281,7 +304,7 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [] assert get_datasets.call_args.kwargs["include_own_datasets"] is False @@ -308,7 +331,7 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) assert get_datasets.call_args.kwargs["accessible_dataset_ids"] is None def test_get_restricted_whitelist_overrides_default_read_permission( @@ -374,7 +397,7 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [ "dataset-whitelist-only", ] @@ -399,9 +422,9 @@ class TestDatasetList: ), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - method(api, MagicMock(), "tenant-1", current_user) + method(api, self.session, "tenant-1", current_user) session = get_datasets_by_ids.call_args.kwargs["session"] - assert isinstance(session, MagicMock) + assert session is self.session assert get_datasets_by_ids.call_args.args == (["dataset-1"], "tenant-1") assert get_datasets_by_ids.call_args.kwargs == { "user": current_user, @@ -420,7 +443,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 def test_get_allows_legacy_weighted_score_without_weight_type(self, app: Flask): @@ -453,7 +476,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 assert resp["data"][0]["retrieval_model_dict"]["weights"]["weight_type"] is None @@ -467,7 +490,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=MagicMock(get_models=lambda **_: [])), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert status == 200 retrieval_model = resp["data"][0]["retrieval_model_dict"] assert retrieval_model["search_method"] == "semantic_search" @@ -491,7 +514,7 @@ class TestDatasetList: patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), patch.object(ProviderManager, "get_configurations", return_value=config), ): - resp, status = method(api, MagicMock(), "tenant-1", current_user) + resp, status = method(api, self.session, "tenant-1", current_user) assert resp["data"][0]["embedding_available"] is False def test_partial_members_permission(self, app: Flask): @@ -499,8 +522,9 @@ class TestDatasetList: method = unwrap(api.get) current_user = self._mock_user() datasets = [make_dataset(permission="partial_members")] - session = MagicMock() - session.execute.return_value.all.return_value = [("ds-1", "u1")] + session = self.session + session.add(DatasetPermission(dataset_id="ds-1", account_id="u1", tenant_id="tenant-1")) + session.flush() with app.test_request_context("/datasets"): with ( patch.object(DatasetService, "get_datasets", return_value=(datasets, 1)), @@ -510,7 +534,7 @@ class TestDatasetList: assert resp["data"][0]["partial_member_list"] == ["u1"] -class TestDatasetListApiPost: +class TestDatasetListApiPost(_UsesSQLiteSession): def test_post_success(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -522,7 +546,7 @@ class TestDatasetListApiPost: patch.object(type(console_ns), "payload", payload), patch.object(DatasetService, "create_empty_dataset", return_value=dataset), ): - _, status = method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user) + _, status = method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user) assert status == 201 def test_post_forbidden(self, app: Flask): @@ -532,7 +556,7 @@ class TestDatasetListApiPost: user = make_account(TenantAccountRole.NORMAL) with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload): with pytest.raises(Forbidden): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user) def test_post_duplicate_name(self, app: Flask): api = DatasetListApi() @@ -547,14 +571,14 @@ class TestDatasetListApiPost: ), ): with pytest.raises(DatasetNameDuplicateError): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", user) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", user) def test_post_invalid_payload_missing_name(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) with app.test_request_context("/datasets", json={}), patch.object(type(console_ns), "payload", {}): with pytest.raises(ValueError): - method(api, DatasetCreatePayload(), MagicMock(), "tenant-1", make_account()) + method(api, DatasetCreatePayload(), self.session, "tenant-1", make_account()) def test_post_invalid_indexing_technique(self, app: Flask): api = DatasetListApi() @@ -562,7 +586,7 @@ class TestDatasetListApiPost: payload = {"name": "bad", "indexing_technique": "invalid-tech"} with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload): with pytest.raises(ValueError, match="Invalid indexing technique"): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", make_account()) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", make_account()) def test_post_invalid_provider(self, app: Flask): api = DatasetListApi() @@ -570,10 +594,10 @@ class TestDatasetListApiPost: payload = {"name": "bad", "provider": "unknown"} with app.test_request_context("/datasets", json=payload), patch.object(type(console_ns), "payload", payload): with pytest.raises(ValueError, match="Invalid provider"): - method(api, DatasetCreatePayload(**payload), MagicMock(), "tenant-1", make_account()) + method(api, DatasetCreatePayload(**payload), self.session, "tenant-1", make_account()) -class TestDatasetApiGet: +class TestDatasetApiGet(_UsesSQLiteSession): def test_get_success_basic(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -588,7 +612,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, status = method(api, MagicMock(), tenant_id, user, dataset_id) + data, status = method(api, self.session, tenant_id, user, dataset_id) assert status == 200 assert data["embedding_available"] is True @@ -597,7 +621,7 @@ class TestDatasetApiGet: api = DatasetApi() method = unwrap(api.get) dataset_id = "123e4567-e89b-12d3-a456-426614174000" - user = MagicMock(id="account-1") + user = make_account() tenant_id = "tenant-1" dataset = make_dataset(id=dataset_id) with ( @@ -619,7 +643,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, status = method(api, MagicMock(), tenant_id, user, dataset_id) + data, status = method(api, self.session, tenant_id, user, dataset_id) get_permissions.assert_called_once_with(tenant_id, user.id, dataset_id=dataset_id, session=ANY) assert status == 200 assert data["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] @@ -636,7 +660,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, status = method(api, MagicMock(), "tenant", make_account(), dataset_id) + data, status = method(api, self.session, "tenant", make_account(), dataset_id) assert status == 200 assert data["external_retrieval_model"] == {"top_k": 2, "score_threshold": 0.0, "score_threshold_enabled": None} @@ -649,7 +673,7 @@ class TestDatasetApiGet: patch.object(DatasetService, "get_dataset", return_value=None), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, MagicMock(), "tenant", make_account(), dataset_id) + method(api, self.session, "tenant", make_account(), dataset_id) def test_get_permission_denied(self, app: Flask): api = DatasetApi() @@ -666,7 +690,7 @@ class TestDatasetApiGet: ), ): with pytest.raises(Forbidden, match="no access"): - method(api, MagicMock(), "tenant", make_account(), dataset_id) + method(api, self.session, "tenant", make_account(), dataset_id) def test_get_high_quality_embedding_unavailable(self, app: Flask): api = DatasetApi() @@ -687,7 +711,7 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, _ = method(api, MagicMock(), tenant_id, user, dataset_id) + data, _ = method(api, self.session, tenant_id, user, dataset_id) assert data["embedding_available"] is False def test_get_partial_members_permission(self, app: Flask): @@ -704,11 +728,11 @@ class TestDatasetApiGet: patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, ): provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] - data, _ = method(api, MagicMock(), "tenant", make_account(), dataset_id) + data, _ = method(api, self.session, "tenant", make_account(), dataset_id) assert data["partial_member_list"] == partial_members -class TestDatasetApiPatch: +class TestDatasetApiPatch(_UsesSQLiteSession): def test_patch_success_basic(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -725,7 +749,7 @@ class TestDatasetApiPatch: patch.object(DatasetService, "update_dataset", return_value=dataset), patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=[]), ): - result, status = method(api, DatasetUpdatePayload(), MagicMock(), tenant_id, user, dataset_id) + result, status = method(api, DatasetUpdatePayload(), self.session, tenant_id, user, dataset_id) assert status == 200 assert result["partial_member_list"] == [] @@ -737,7 +761,7 @@ class TestDatasetApiPatch: patch.object(DatasetService, "get_dataset", return_value=None), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, DatasetUpdatePayload(), MagicMock(), "tenant-1", make_account(), "missing") + method(api, DatasetUpdatePayload(), self.session, "tenant-1", make_account(), "missing") def test_patch_permission_denied(self, app: Flask): api = DatasetApi() @@ -752,7 +776,7 @@ class TestDatasetApiPatch: patch.object(DatasetPermissionService, "check_permission", side_effect=Forbidden("no permission")), ): with pytest.raises(Forbidden): - method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id) + method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id) def test_patch_partial_members_update(self, app: Flask): api = DatasetApi() @@ -769,7 +793,7 @@ class TestDatasetApiPatch: patch.object(DatasetPermissionService, "update_partial_member_list", return_value=None), patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=["u1", "u2"]), ): - result, _ = method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id) + result, _ = method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id) assert result["partial_member_list"] == ["u1", "u2"] def test_patch_clear_partial_members(self, app: Flask): @@ -787,11 +811,11 @@ class TestDatasetApiPatch: patch.object(DatasetPermissionService, "clear_partial_member_list", return_value=None), patch.object(DatasetPermissionService, "get_dataset_partial_member_list", return_value=[]), ): - result, _ = method(api, DatasetUpdatePayload(), MagicMock(), "tenant", make_account(), dataset_id) + result, _ = method(api, DatasetUpdatePayload(), self.session, "tenant", make_account(), dataset_id) assert result["partial_member_list"] == [] -class TestDatasetApiDelete: +class TestDatasetApiDelete(_UsesSQLiteSession): def test_delete_success(self, app: Flask): api = DatasetApi() method = unwrap(api.delete) @@ -802,7 +826,7 @@ class TestDatasetApiDelete: patch.object(DatasetService, "delete_dataset", return_value=True), patch.object(DatasetPermissionService, "clear_partial_member_list", return_value=None), ): - result, status = method(api, MagicMock(), user, dataset_id) + result, status = method(api, self.session, user, dataset_id) assert status == 204 assert result == "" @@ -813,7 +837,7 @@ class TestDatasetApiDelete: user = make_account(TenantAccountRole.NORMAL) with app.test_request_context(f"/datasets/{dataset_id}"): with pytest.raises(Forbidden): - method(api, MagicMock(), user, dataset_id) + method(api, self.session, user, dataset_id) def test_delete_dataset_not_found(self, app: Flask): api = DatasetApi() @@ -825,7 +849,7 @@ class TestDatasetApiDelete: patch.object(DatasetService, "delete_dataset", return_value=False), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, MagicMock(), user, dataset_id) + method(api, self.session, user, dataset_id) def test_delete_dataset_in_use(self, app: Flask): api = DatasetApi() @@ -837,10 +861,10 @@ class TestDatasetApiDelete: patch.object(DatasetService, "delete_dataset", side_effect=services.errors.dataset.DatasetInUseError()), ): with pytest.raises(DatasetInUseError): - method(api, MagicMock(), user, dataset_id) + method(api, self.session, user, dataset_id) -class TestDatasetUseCheckApi: +class TestDatasetUseCheckApi(_UsesSQLiteSession): @pytest.mark.parametrize("is_using", [True, False]) def test_get_use_check(self, app: Flask, is_using: bool): api = DatasetUseCheckApi() @@ -848,7 +872,7 @@ class TestDatasetUseCheckApi: dataset_id = "dataset-id" dataset = make_dataset(id=dataset_id) current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context(f"/datasets/{dataset_id}/use-check"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -867,7 +891,7 @@ class TestDatasetUseCheckApi: api = DatasetUseCheckApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-id") - session = MagicMock() + session = self.session with ( app.test_request_context("/datasets/dataset-id/use-check"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -884,11 +908,11 @@ class TestDatasetUseCheckApi: "api_cls", [DatasetUseCheckApi, DatasetIndexingStatusApi, DatasetErrorDocs, DatasetAutoDisableLogApi], ) -def test_dataset_scoped_read_permission_denied(app: Flask, api_cls): +def test_dataset_scoped_read_permission_denied(app: Flask, api_cls, sqlite_session: Session): api = api_cls() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") - session = MagicMock() + session = sqlite_session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -902,7 +926,7 @@ def test_dataset_scoped_read_permission_denied(app: Flask, api_cls): method(api, session, "tenant-1", make_account(), "dataset-1") -class TestDatasetQueryApi: +class TestDatasetQueryApi(_UsesSQLiteSession): def _query_record(self, index: int = 1) -> DatasetQuery: query = DatasetQuery( dataset_id="dataset-id", @@ -929,7 +953,7 @@ class TestDatasetQueryApi: patch.object(DatasetService, "check_dataset_permission", return_value=None), patch.object(DatasetService, "get_dataset_queries", return_value=(queries, 2)), ): - response, status = method(api, MagicMock(), current_user, dataset_id) + response, status = method(api, self.session, current_user, dataset_id) assert status == 200 assert response["total"] == 2 assert response["page"] == 1 @@ -952,24 +976,30 @@ class TestDatasetQueryApi: dataset = make_dataset(id="dataset-id") query = self._query_record() query.content = json.dumps([{"content_type": "image_query", "content": "file-1"}]) - upload_file = SimpleNamespace( - id="file-1", + upload_file = UploadFile( + tenant_id="tenant-1", + storage_type=StorageType.LOCAL, + key="image.png", name="image.png", size=10, extension="png", mime_type="image/png", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + created_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + used=False, ) - session = MagicMock() - session.scalar.return_value = upload_file + upload_file.id = "file-1" + session = self.session + session.add(upload_file) + session.flush() with ( app.test_request_context("/datasets/queries"), patch.object(DatasetService, "get_dataset", return_value=dataset), patch.object(DatasetService, "check_dataset_permission", return_value=None), patch.object(DatasetService, "get_dataset_queries", return_value=([query], 1)), - patch("models.dataset.db") as db_mock, patch("models.dataset.sign_upload_file_preview_url", return_value="signed-url"), ): - db_mock.session.scalar.return_value = upload_file response, status = method(api, session, make_account(), "dataset-id") assert status == 200 @@ -987,8 +1017,7 @@ class TestDatasetQueryApi: }, } ] - session.scalar.assert_called_once() - db_mock.session.scalar.assert_not_called() + assert session.get(UploadFile, "file-1") is upload_file def test_get_queries_dataset_not_found(self, app: Flask): api = DatasetQueryApi() @@ -1000,7 +1029,7 @@ class TestDatasetQueryApi: patch.object(DatasetService, "get_dataset", return_value=None), ): with pytest.raises(NotFound, match="Dataset not found"): - method(api, MagicMock(), current_user, dataset_id) + method(api, self.session, current_user, dataset_id) def test_get_queries_permission_denied(self, app: Flask): api = DatasetQueryApi() @@ -1018,7 +1047,7 @@ class TestDatasetQueryApi: ), ): with pytest.raises(Forbidden): - method(api, MagicMock(), current_user, dataset_id) + method(api, self.session, current_user, dataset_id) def test_get_queries_pagination_has_more(self, app: Flask): api = DatasetQueryApi() @@ -1033,13 +1062,13 @@ class TestDatasetQueryApi: patch.object(DatasetService, "check_dataset_permission", return_value=None), patch.object(DatasetService, "get_dataset_queries", return_value=(queries, 40)), ): - response, status = method(api, MagicMock(), current_user, dataset_id) + response, status = method(api, self.session, current_user, dataset_id) assert status == 200 assert response["has_more"] is True assert len(response["data"]) == 20 -class TestDatasetIndexingEstimateApi: +class TestDatasetIndexingEstimateApi(_UsesSQLiteSession): def _upload_file(self, *, tenant_id: str = "tenant-1", file_id: str = "file-1") -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id, @@ -1072,8 +1101,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) payload = self._base_payload() mock_file = self._upload_file() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() mock_response = IndexingEstimate(total_segments=100, preview=[]) @@ -1102,8 +1132,7 @@ class TestDatasetIndexingEstimateApi: api = DatasetIndexingEstimateApi() method = unwrap(api.post) payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = None + session = self.session with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1122,8 +1151,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) mock_file = self._upload_file() payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1146,8 +1176,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) mock_file = self._upload_file() payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1170,8 +1201,9 @@ class TestDatasetIndexingEstimateApi: method = unwrap(api.post) mock_file = self._upload_file() payload = self._base_payload() - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_file] + session = self.session + session.add(mock_file) + session.flush() with ( app.test_request_context("/"), patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload), @@ -1189,16 +1221,16 @@ class TestDatasetIndexingEstimateApi: ) -class TestDatasetRelatedAppListApi: +class TestDatasetRelatedAppListApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") app1 = make_related_app(id="app-1", name="App 1") app2 = make_related_app(id="app-2", name="App 2") - join1 = MagicMock(app_id="app-1") - join2 = MagicMock(app_id="app-2") - session = MagicMock() + join1 = AppDatasetJoin(app_id="app-1", dataset_id="dataset-1") + join2 = AppDatasetJoin(app_id="app-2", dataset_id="dataset-1") + session = self.session with ( app.test_request_context("/"), patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=dataset), @@ -1251,7 +1283,7 @@ class TestDatasetRelatedAppListApi: patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=None), ): with pytest.raises(NotFound): - method(api, MagicMock(), make_account(), "dataset-1") + method(api, self.session, make_account(), "dataset-1") def test_get_permission_denied(self, app: Flask): api = DatasetRelatedAppListApi() @@ -1266,16 +1298,16 @@ class TestDatasetRelatedAppListApi: ), ): with pytest.raises(Forbidden): - method(api, MagicMock(), make_account(), "dataset-1") + method(api, self.session, make_account(), "dataset-1") def test_get_filters_none_apps(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") app1 = make_related_app() - join1 = MagicMock(app_id="app-1") - join2 = MagicMock(app_id="app-2") - session = MagicMock() + join1 = AppDatasetJoin(app_id="app-1", dataset_id="dataset-1") + join2 = AppDatasetJoin(app_id="app-2", dataset_id="dataset-1") + session = self.session with ( app.test_request_context("/"), patch("controllers.console.datasets.datasets.DatasetService.get_dataset", return_value=dataset), @@ -1303,26 +1335,17 @@ class TestDatasetRelatedAppListApi: ] -class TestDatasetIndexingStatusApi: +class TestDatasetIndexingStatusApi(_UsesSQLiteSession): def test_get_success_with_documents(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") current_user = make_account() - document = MagicMock() - document.id = "doc-1" - document.indexing_status = "completed" - document.processing_started_at = None - document.parsing_completed_at = None - document.cleaning_completed_at = None - document.splitting_completed_at = None - document.completed_at = None - document.paused_at = None - document.error = None - document.stopped_at = None - session = MagicMock() - session.scalars.return_value.all.return_value = [document] - session.scalar.return_value = 3 + document = make_document_status() + session = self.session + session.add(document) + session.add_all([make_document_segment(position=position, completed=True) for position in range(1, 4)]) + session.flush() with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1337,16 +1360,13 @@ class TestDatasetIndexingStatusApi: assert item["total_segments"] == 3 get_dataset.assert_called_once_with("dataset-1", "tenant-1", session=session) check_permission.assert_called_once_with(dataset, current_user, session) - assert {"dataset-1", "tenant-1"} <= set(session.scalars.call_args.args[0].compile().params.values()) - for segment_count_call in session.scalar.call_args_list: - assert {"dataset-1", "tenant-1", "doc-1"} <= set(segment_count_call.args[0].compile().params.values()) + assert session.get(Document, "doc-1") is document def test_get_success_no_documents(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") - session = MagicMock() - session.scalars.return_value.all.return_value = [] + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1360,20 +1380,11 @@ class TestDatasetIndexingStatusApi: api = DatasetIndexingStatusApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") - document = MagicMock() - document.id = "doc-1" - document.indexing_status = "indexing" - document.processing_started_at = None - document.parsing_completed_at = None - document.cleaning_completed_at = None - document.splitting_completed_at = None - document.completed_at = None - document.paused_at = None - document.error = None - document.stopped_at = None - session = MagicMock() - session.scalars.return_value.all.return_value = [document] - session.scalar.side_effect = [2, 5] + document = make_document_status(indexing_status=IndexingStatus.INDEXING) + session = self.session + session.add(document) + session.add_all([make_document_segment(position=position, completed=position <= 2) for position in range(1, 6)]) + session.flush() with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1386,7 +1397,7 @@ class TestDatasetIndexingStatusApi: assert item["total_segments"] == 5 -class TestDatasetApiKeyApi: +class TestDatasetApiKeyApi(_UsesSQLiteSession): def test_get_api_keys_success(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.get) @@ -1404,8 +1415,11 @@ class TestDatasetApiKeyApi: last_used_at=None, created_at=None, ) - session = MagicMock() - session.scalars.return_value.all.return_value = [mock_key_1, mock_key_2] + session = self.session + mock_key_1.tenant_id = "tenant-1" + mock_key_2.tenant_id = "tenant-1" + session.add_all([mock_key_1, mock_key_2]) + session.flush() with app.test_request_context("/"): response = method(api, session, "tenant-1") assert "data" in response @@ -1418,30 +1432,31 @@ class TestDatasetApiKeyApi: def test_post_create_api_key_success(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.post) - mock_token = MagicMock() - mock_token.id = "new-key-id" - mock_token.last_used_at = None - mock_token.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC) - mock_api_token_cls = MagicMock() - mock_api_token_cls.return_value = mock_token - mock_api_token_cls.generate_api_key.return_value = "dataset-abc123" - session = MagicMock() - session.scalar.return_value = 3 - with app.test_request_context("/"), patch("controllers.console.datasets.datasets.ApiToken", mock_api_token_cls): + session = self.session + with ( + app.test_request_context("/"), + patch.object(ApiToken, "generate_api_key", return_value="dataset-abc123") as generate_api_key, + ): response, status = method(api, session, "tenant-1") assert status == 200 assert isinstance(response, dict) - assert response["id"] == "new-key-id" assert response["token"] == "dataset-abc123" assert response["type"] == "dataset" assert response["created_at"] is not None - mock_api_token_cls.generate_api_key.assert_called_once_with("dataset-", 24, session=session) + generate_api_key.assert_called_once_with("dataset-", 24, session=session) + assert session.get(ApiToken, response["id"]).token == "dataset-abc123" def test_post_exceed_max_keys(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.post) - session = MagicMock() - session.scalar.return_value = 10 + session = self.session + session.add_all( + [ + ApiToken(id=f"key-{index}", tenant_id="tenant-1", type="dataset", token=f"ds-{index}") + for index in range(10) + ] + ) + session.flush() with app.test_request_context("/"): with pytest.raises(BadRequest) as exc_info: method(api, session, "tenant-1") @@ -1452,36 +1467,42 @@ class TestDatasetApiKeyApi: } -class TestDatasetApiDeleteApi: +class TestDatasetApiDeleteApi(_UsesSQLiteSession): def test_delete_success(self, app: Flask): api = DatasetApiDeleteApi() method = unwrap(api.delete) - mock_key = MagicMock() - session = MagicMock() - session.scalar.return_value = mock_key - with app.test_request_context("/"): + session = self.session + key = ApiToken(id="api-key-id", tenant_id="tenant-1", type="dataset", token="dataset-secret") + session.add(key) + session.flush() + with ( + app.test_request_context("/"), + patch("controllers.console.datasets.datasets.ApiTokenCache.delete") as delete_cache, + ): response, status = method(api, session, "tenant-1", "api-key-id") assert status == 204 assert response == "" + delete_cache.assert_called_once() + session.flush() + assert session.get(ApiToken, "api-key-id") is None def test_delete_key_not_found(self, app: Flask): api = DatasetApiDeleteApi() method = unwrap(api.delete) - session = MagicMock() - session.scalar.return_value = None + session = self.session with app.test_request_context("/"): with pytest.raises(NotFound): method(api, session, "tenant-1", "api-key-id") -class TestDatasetEnableApiApi: +class TestDatasetEnableApiApi(_UsesSQLiteSession): @pytest.mark.parametrize(("status_value", "enabled"), [("enable", True), ("disable", False)]) def test_update_api_status(self, app: Flask, status_value: str, enabled: bool): api = DatasetEnableApiApi() method = unwrap(api.post) dataset = make_dataset(id="dataset-1") current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1499,7 +1520,7 @@ class TestDatasetEnableApiApi: api = DatasetEnableApiApi() method = unwrap(api.post) dataset = make_dataset(id="dataset-1") - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), @@ -1582,7 +1603,7 @@ class TestDatasetRetrievalSettingApi: ] -class TestDatasetRetrievalSettingMockApi: +class TestDatasetRetrievalSettingMockApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetRetrievalSettingMockApi() method = unwrap(api.get) @@ -1597,14 +1618,14 @@ class TestDatasetRetrievalSettingMockApi: assert response["retrieval_method"] == ["semantic"] -class TestDatasetErrorDocs: +class TestDatasetErrorDocs(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetErrorDocs() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") error_doc = make_document_status(id="error-doc", indexing_status=IndexingStatus.ERROR, error="failed") current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1624,7 +1645,7 @@ class TestDatasetErrorDocs: def test_get_dataset_not_found(self, app: Flask): api = DatasetErrorDocs() method = unwrap(api.get) - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=None) as get_dataset, @@ -1634,7 +1655,7 @@ class TestDatasetErrorDocs: get_dataset.assert_called_once_with("dataset-1", "tenant-1", session=session) -class TestDatasetPermissionUserListApi: +class TestDatasetPermissionUserListApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetPermissionUserListApi() method = unwrap(api.get) @@ -1649,7 +1670,7 @@ class TestDatasetPermissionUserListApi: return_value=users, ), ): - response, status = method(api, MagicMock(), make_account(), "dataset-1") + response, status = method(api, self.session, make_account(), "dataset-1") assert status == 200 assert response["data"] == users @@ -1666,17 +1687,17 @@ class TestDatasetPermissionUserListApi: ), ): with pytest.raises(Forbidden): - method(api, MagicMock(), make_account(), "dataset-1") + method(api, self.session, make_account(), "dataset-1") -class TestDatasetAutoDisableLogApi: +class TestDatasetAutoDisableLogApi(_UsesSQLiteSession): def test_get_success(self, app: Flask): api = DatasetAutoDisableLogApi() method = unwrap(api.get) dataset = make_dataset(id="dataset-1") logs = {"document_ids": ["doc-1"], "count": 1} current_user = make_account() - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset) as get_dataset, @@ -1693,7 +1714,7 @@ class TestDatasetAutoDisableLogApi: def test_get_dataset_not_found(self, app: Flask): api = DatasetAutoDisableLogApi() method = unwrap(api.get) - session = MagicMock() + session = self.session with ( app.test_request_context("/"), patch.object(DatasetService, "get_dataset_for_tenant", return_value=None) as get_dataset, diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py index 459af12ba76..060b6029bc7 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py @@ -55,6 +55,7 @@ from services.vector_space_admission_service import ( VECTOR_SPACE_ADMISSION_ERROR_CODE, format_vector_space_admission_error, ) +from tests.unit_tests.config_override import config_overrides_context def make_serializable_document(**overrides): @@ -504,7 +505,7 @@ class TestDatasetInitApi: with ( app.test_request_context("/", json=payload), patch.object(type(console_ns), "payload", payload), - patch("controllers.console.datasets.datasets_document.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.console.datasets.datasets_document.DocumentService.document_create_args_validate", return_value=None, @@ -560,7 +561,7 @@ class TestDocumentResource: api = DocumentResource() session = MagicMock() with ( - patch("controllers.console.datasets.datasets_document.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.console.datasets.datasets_document.DatasetService.get_dataset_for_tenant", return_value=dataset, diff --git a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py index 6cf5664bd96..696e0223255 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py @@ -22,6 +22,7 @@ from services.entities.knowledge_entities.knowledge_entities import MetadataArgs from services.errors.account import NoPermissionError from services.errors.metadata import MetadataResourceNotFoundError from services.metadata_service import MetadataService +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -150,7 +151,7 @@ class TestDatasetMetadataGetApi: method = unwrap(api.get) with ( app.test_request_context("/"), - patch("controllers.console.datasets.metadata.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(DatasetService, "get_dataset_for_tenant", return_value=dataset), patch.object(DatasetService, "check_dataset_permission") as check_permission, patch.object( diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index ad4de5468ab..8af0e633620 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -37,6 +37,32 @@ def _snippet(**overrides) -> CustomizedSnippet: return CustomizedSnippet(**data) +def _workflow(**overrides) -> SimpleNamespace: + data = { + "id": "workflow-1", + "graph_dict": {"nodes": [], "edges": []}, + "features_dict": {}, + "unique_hash": "hash-1", + "version": "2024-01-01 00:00:00", + "marked_name": "v1", + "marked_comment": "first version", + "created_by_account": None, + "created_at": datetime(2024, 1, 1), + "updated_by_account": None, + "updated_at": datetime(2024, 1, 1), + "tool_published": False, + "environment_variables": [], + "conversation_variables": [], + "rag_pipeline_variables": [], + } + data.update(overrides) + workflow = SimpleNamespace(**data) + workflow.get_created_by_account = Mock(return_value=workflow.created_by_account) + workflow.get_updated_by_account = Mock(return_value=workflow.updated_by_account) + workflow.get_tool_published = Mock(return_value=workflow.tool_published) + return workflow + + @pytest.fixture(autouse=True) def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: snippet_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) @@ -114,6 +140,34 @@ def test_draft_workflow_get_raises_when_missing(app: Flask, monkeypatch: pytest. handler(api, snippet=snippet) +def test_draft_workflow_get_uses_session_aware_response_source(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _workflow() + snippet = _snippet() + session = Mock(spec=Session) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(session=Mock(return_value=session))) + monkeypatch.setattr( + snippet_workflow_module, + "_snippet_service", + lambda: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr( + snippet_workflow_module.WorkflowAgentPublishService, + "project_draft_bindings_to_graph", + Mock(return_value=workflow.graph_dict), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowApi() + handler = unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/draft"): + response = handler(api, snippet=snippet) + + assert response["id"] == "workflow-1" + workflow.get_created_by_account.assert_called_once_with(session=session) + workflow.get_updated_by_account.assert_called_once_with(session=session) + workflow.get_tool_published.assert_called_once_with(session=session) + + def test_draft_workflow_post_returns_400_for_invalid_graph(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() @@ -161,6 +215,29 @@ def test_published_workflow_get_returns_none_when_not_published(app) -> None: assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None +def test_published_workflow_get_uses_session_aware_response_source(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _workflow() + session = Mock(spec=Session) + snippet = SimpleNamespace(id="snippet-1", is_published=True, input_fields_list=[]) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(session=Mock(return_value=session))) + monkeypatch.setattr( + snippet_workflow_module, + "_snippet_service", + lambda: SimpleNamespace(get_published_workflow=Mock(return_value=workflow)), + ) + + api = snippet_workflow_module.SnippetPublishedWorkflowApi() + handler = unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/publish"): + response = handler(api, snippet=snippet) + + assert response["id"] == "workflow-1" + workflow.get_created_by_account.assert_called_once_with(session=session) + workflow.get_updated_by_account.assert_called_once_with(session=session) + workflow.get_tool_published.assert_called_once_with(session=session) + + @pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) def test_published_workflow_post_returns_400_when_publish_fails( app: Flask, @@ -247,23 +324,7 @@ def test_list_published_snippet_workflows_includes_input_fields( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, ) -> None: - workflow = SimpleNamespace( - id="workflow-1", - graph_dict={"nodes": [], "edges": []}, - features_dict={}, - unique_hash="hash-1", - version="2024-01-01 00:00:00", - marked_name="", - marked_comment="", - created_by_account=None, - created_at=datetime(2024, 1, 1), - updated_by_account=None, - updated_at=datetime(2024, 1, 1), - tool_published=False, - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) + workflow = _workflow(marked_name="", marked_comment="") input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) @@ -406,23 +467,7 @@ def test_update_published_snippet_workflow_returns_updated_workflow( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: - workflow = SimpleNamespace( - id="workflow-1", - graph_dict={"nodes": [], "edges": []}, - features_dict={}, - unique_hash="hash-1", - version="2024-01-01 00:00:00", - marked_name="v1", - marked_comment="first version", - created_by_account=None, - created_at=datetime(2024, 1, 1), - updated_by_account=None, - updated_at=datetime(2024, 1, 1), - tool_published=False, - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) + workflow = _workflow() user = _account("account-1") input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 573698ed9e4..5d19e08a557 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -31,6 +31,7 @@ from services.tag_application_service import ( TagSummary, UpdateTagInput, ) +from tests.unit_tests.config_override import config_overrides_context def unwrap(func): @@ -142,7 +143,7 @@ class TestTagListApi: with ( app.test_request_context("/", json={"name": "Tag", "type": "knowledge"}), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(dataset_operator, "tenant-1")), ): result, status = unwrap(TagListApi().post)( @@ -163,7 +164,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -186,7 +187,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): @@ -204,7 +205,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(ValueError, match="Tag name already exists") as exc_info: @@ -225,7 +226,7 @@ class TestTagListApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(TagApplicationError, match="unexpected"): @@ -246,7 +247,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -273,7 +274,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): @@ -292,7 +293,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(NotFound, match="Tag not found") as exc_info: @@ -313,7 +314,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(dataset_operator, "tenant-1")), ): with pytest.raises(Forbidden): @@ -326,7 +327,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagUpdateDeleteApi().delete)(TagUpdateDeleteApi(), request_context, "tag-1") @@ -342,7 +343,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -360,7 +361,7 @@ class TestTagUpdateDeleteApi: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), patch.object(module, "enforce_rbac_access") as enforce_rbac_access, ): @@ -383,7 +384,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagBindingCollectionApi().post)(TagBindingCollectionApi(), payload, request_context) @@ -402,7 +403,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagBindingCollectionApi().post)(TagBindingCollectionApi(), payload, request_context) @@ -422,7 +423,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(NotFound, match="App not found") as exc_info: @@ -437,7 +438,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): @@ -455,7 +456,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): result, status = unwrap(TagBindingRemoveApi().post)(TagBindingRemoveApi(), payload, request_context) @@ -475,7 +476,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")), ): with pytest.raises(NotFound, match="Dataset not found") as exc_info: @@ -490,7 +491,7 @@ class TestTagBindings: with ( app.test_request_context("/"), - patch.object(module.dify_config, "RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch.object(module, "current_account_with_tenant", return_value=(readonly, "tenant-1")), ): with pytest.raises(Forbidden): diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 97bad4420f7..ea60db4a1a3 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -9,6 +9,8 @@ import pytest from flask import Flask from flask.views import MethodView as FlaskMethodView +from tests.unit_tests.config_override import apply_config_overrides + _NEEDS_METHOD_VIEW_CLEANUP = False if not hasattr(builtins, "MethodView"): builtins.__dict__["MethodView"] = FlaskMethodView @@ -63,9 +65,12 @@ def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> Account: account.id = "account-123" account._current_tenant = tenant - monkeypatch.setattr(wraps_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(wraps_module.dify_config, "INIT_PASSWORD", "") - monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + INIT_PASSWORD="", + LOGIN_DISABLED=True, + ) monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (account, "tenant-123")) # The login_required decorator consults the shared LocalProxy in libs.login. diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py index 1d73ee30a68..dc8604a88f7 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_init_validate.py @@ -14,6 +14,7 @@ from services.init_validation_service import ( InitValidationService, InvalidInitializationPasswordError, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -59,10 +60,7 @@ def test_validate_init_password_success( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) client = app.test_client() response = client.post("/console/api/init", json={"password": "expected"}) @@ -79,10 +77,7 @@ def test_validate_init_password_rejects_a_mismatch( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = InvalidInitializationPasswordError client = app.test_client() @@ -98,10 +93,7 @@ def test_validate_init_password_rejects_an_initialized_installation( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = AlreadyInitializedError response = app.test_client().post("/console/api/init", json={"password": "expected"}) @@ -122,10 +114,7 @@ def test_validate_init_password_rejects_an_invalid_payload( monkeypatch: pytest.MonkeyPatch, payload: dict[str, str], ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) response = app.test_client().post("/console/api/init", json=payload) @@ -138,10 +127,7 @@ def test_validate_init_password_is_not_available_in_cloud( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.CLOUD, - ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) response = app.test_client().post("/console/api/init", json={"password": "expected"}) diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py index 08aa94e6ca3..e82eca70956 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_system.py @@ -1,5 +1,4 @@ import builtins -from unittest.mock import patch import pytest from flask.views import MethodView @@ -7,6 +6,7 @@ from flask.views import MethodView from configs import dify_config from dify_app import DifyApp from extensions import ext_fastopenapi +from tests.unit_tests.config_override import config_overrides_context if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @@ -31,7 +31,7 @@ def test_console_ping_fastopenapi_returns_pong(app: DifyApp) -> None: def test_console_version_fastopenapi_returns_current_version(app: DifyApp) -> None: ext_fastopenapi.init_app(app) - with patch("controllers.console.system.dify_config.CHECK_UPDATE_URL", None): + with config_overrides_context(CHECK_UPDATE_URL=None): response = app.test_client().get("/console/api/version", query_string={"current_version": "0.0.0"}) assert response.status_code == 200 diff --git a/api/tests/unit_tests/controllers/console/test_human_input_form.py b/api/tests/unit_tests/controllers/console/test_human_input_form.py index 7265f98b504..39e8f089ce9 100644 --- a/api/tests/unit_tests/controllers/console/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/console/test_human_input_form.py @@ -28,6 +28,7 @@ from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.human_input import RecipientType from models.model import App, AppMode from models.workflow import WorkflowRun +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -256,7 +257,7 @@ def test_post_form_decorated_success_validates_request_body(app: Flask, monkeypa "controllers.console.wraps.current_account_with_tenant", lambda: (current_user, "tenant-1"), ) - monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=True) with app.test_request_context( "/console/api/form/human_input/token", diff --git a/api/tests/unit_tests/controllers/console/test_init_validate.py b/api/tests/unit_tests/controllers/console/test_init_validate.py index 80145f7cae6..cfd6efd230b 100644 --- a/api/tests/unit_tests/controllers/console/test_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_init_validate.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, create_autospec import pytest from flask import Flask -from controllers.console import init_validate, wraps +from controllers.console import init_validate from controllers.console.error import AlreadySetupError, InitValidateFailedError from enums import DeploymentEdition from services.init_validation_service import ( @@ -14,6 +14,7 @@ from services.init_validation_service import ( InitValidationService, InvalidInitializationPasswordError, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -49,7 +50,7 @@ def test_validate_init_password_already_setup( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = AlreadyInitializedError app.secret_key = "test-secret" @@ -63,7 +64,7 @@ def test_validate_init_password_wrong_password( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) init_validation.validate_password.side_effect = InvalidInitializationPasswordError app.secret_key = "test-secret" @@ -78,7 +79,7 @@ def test_validate_init_password_success( init_validation: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) app.secret_key = "test-secret" with app.test_request_context("/console/api/init", method="POST"): diff --git a/api/tests/unit_tests/controllers/console/test_notification.py b/api/tests/unit_tests/controllers/console/test_notification.py new file mode 100644 index 00000000000..48843d1af8a --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_notification.py @@ -0,0 +1,77 @@ +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from controllers.console.notification import ( + DismissNotificationPayload, + NotificationApi, + NotificationDismissApi, +) +from machinery.context import RequestContext +from services.entities.notification_entities import NotificationItem, NotificationResult + + +def _request_context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id="workspace-1", + ) + + +def test_get_notification_delegates_and_serializes_result() -> None: + service = Mock() + service.get_active.return_value = NotificationResult( + should_show=True, + notifications=( + NotificationItem( + notification_id="notification-1", + frequency="once", + lang="en-US", + title="Title", + subtitle="Subtitle", + body="Body", + title_pic_url="https://example.com/title.png", + ), + ), + ) + services = SimpleNamespace(notifications=service) + api = NotificationApi() + method = unwrap(api.get) + context = _request_context() + + with patch("controllers.console.notification.application_services", return_value=services): + result, status = method(api, context) + + assert status == 200 + assert result == { + "should_show": True, + "notifications": [ + { + "notification_id": "notification-1", + "frequency": "once", + "lang": "en-US", + "title": "Title", + "subtitle": "Subtitle", + "body": "Body", + "title_pic_url": "https://example.com/title.png", + } + ], + } + service.get_active.assert_called_once_with(context) + + +def test_dismiss_notification_delegates_with_stable_account_context() -> None: + service = Mock() + services = SimpleNamespace(notifications=service) + api = NotificationDismissApi() + method = unwrap(api.post) + context = _request_context() + + with patch("controllers.console.notification.application_services", return_value=services): + result, status = method(api, DismissNotificationPayload(notification_id="notification-1"), context) + + assert status == 200 + assert result == {"result": "success"} + service.dismiss.assert_called_once_with(context, "notification-1") diff --git a/api/tests/unit_tests/controllers/console/test_onboarding.py b/api/tests/unit_tests/controllers/console/test_onboarding.py index 8d613f7c202..90a9521a2fa 100644 --- a/api/tests/unit_tests/controllers/console/test_onboarding.py +++ b/api/tests/unit_tests/controllers/console/test_onboarding.py @@ -2,47 +2,48 @@ from __future__ import annotations from datetime import UTC, datetime from inspect import unwrap -from unittest.mock import Mock +from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest -from flask import Flask from pydantic import ValidationError from controllers.console.onboarding import ( StepByStepTourStateApi, StepByStepTourStatePatchPayload, + StepByStepTourStateResponse, ) -from extensions.ext_database import db -from models.account import Account, AccountStatus -from services.step_by_step_tour_service import StepByStepTourService +from machinery.context import RequestContext +from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult -def _account() -> Account: - account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE) - account.id = "account-1" - return account +def _request_context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id="workspace-1", + ) -def _state_response() -> dict[str, object]: - return { - "first_workspace_id": "workspace-1", - "skipped": False, - "completed_task_ids": ["home"], - "manually_enabled_workspace_ids": [], - "manually_disabled_workspace_ids": [], - "updated_at": datetime(2026, 6, 28, tzinfo=UTC), - } +def _state_result() -> StepByStepTourResult: + return StepByStepTourResult( + first_workspace_id="workspace-1", + completed_task_ids=("home",), + updated_at=datetime(2026, 6, 28, tzinfo=UTC), + ) -def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - get_state = Mock(return_value=_state_response()) - monkeypatch.setattr(StepByStepTourService, "get_state", get_state) - +def test_get_step_by_step_tour_state_delegates_with_request_context() -> None: + service = Mock() + service.get_state.return_value = _state_result() + services = SimpleNamespace(step_by_step_tour=service) api = StepByStepTourStateApi() method = unwrap(api.get) + context = _request_context() - with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"): - result = method(api, "workspace-1", _account()) + with patch("controllers.console.onboarding.application_services", return_value=services): + result = method(api, context) assert result == { "first_workspace_id": "workspace-1", @@ -52,35 +53,26 @@ def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch "manually_disabled_workspace_ids": [], "updated_at": "2026-06-28T00:00:00Z", } - get_state.assert_called_once() - assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1" - assert get_state.call_args.kwargs["session"] is db.session + service.get_state.assert_called_once_with(context) -def test_patch_step_by_step_tour_state_passes_action_payload( - app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - patch_state = Mock(return_value=_state_response()) - monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state) - +def test_patch_step_by_step_tour_state_maps_transport_payload_to_command() -> None: + service = Mock() + service.patch_state.return_value = _state_result() + services = SimpleNamespace(step_by_step_tour=service) api = StepByStepTourStateApi() method = unwrap(api.patch) - payload = {"action": "complete_task", "task_id": "studio"} + context = _request_context() + payload = StepByStepTourStatePatchPayload.model_validate({"action": "complete_task", "task_id": "studio"}) - req_data = StepByStepTourStatePatchPayload.model_validate(payload) - with app.test_request_context( - "/console/api/onboarding/step-by-step-tour/state", - method="PATCH", - json=payload, - ): - result = method(api, req_data, "workspace-1", _account()) + with patch("controllers.console.onboarding.application_services", return_value=services): + result = method(api, payload, context) assert result["completed_task_ids"] == ["home"] - patch_state.assert_called_once() - assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1" - assert patch_state.call_args.kwargs["patch"] == payload - assert patch_state.call_args.kwargs["session"] is db.session + service.patch_state.assert_called_once_with( + context, + StepByStepTourPatch(action="complete_task", task_id="studio"), + ) def test_patch_payload_rejects_non_action_fields() -> None: @@ -96,3 +88,21 @@ def test_patch_payload_rejects_task_id_without_task_action() -> None: def test_patch_payload_requires_action() -> None: with pytest.raises(ValidationError): StepByStepTourStatePatchPayload.model_validate({"task_id": "home"}) + + +def test_step_by_step_tour_schemas_preserve_enum_values() -> None: + patch_schema = StepByStepTourStatePatchPayload.model_json_schema() + action_schema = patch_schema["properties"]["action"] + task_id_schema = patch_schema["properties"]["task_id"] + task_id_values = next(candidate["enum"] for candidate in task_id_schema["anyOf"] if "enum" in candidate) + response_schema = StepByStepTourStateResponse.model_json_schema() + + assert set(action_schema["enum"]) == { + "skip", + "complete_task", + "uncomplete_task", + "enable_current_workspace", + "disable_current_workspace", + } + assert set(task_id_values) == {"home", "studio", "knowledge", "integration"} + assert set(response_schema["properties"]["completed_task_ids"]["items"]["enum"]) == set(task_id_values) diff --git a/api/tests/unit_tests/controllers/console/test_system.py b/api/tests/unit_tests/controllers/console/test_system.py index 3c390003eb1..8eeb5e48ab1 100644 --- a/api/tests/unit_tests/controllers/console/test_system.py +++ b/api/tests/unit_tests/controllers/console/test_system.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest import controllers.console.system as system_module +from tests.unit_tests.config_override import config_overrides_context class TestHasNewVersion: @@ -37,11 +38,7 @@ class TestCheckVersionUpdate: query = system_module.VersionQuery(current_version="1.0.0") with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "", - ), + config_overrides_context(CHECK_UPDATE_URL=""), patch.object( system_module.dify_config.project, "version", @@ -56,11 +53,7 @@ class TestCheckVersionUpdate: query = system_module.VersionQuery(current_version="1.0.0") with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "http://example.com", - ), + config_overrides_context(CHECK_UPDATE_URL="http://example.com"), patch.object( system_module.httpx, "get", @@ -83,11 +76,7 @@ class TestCheckVersionUpdate: } with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "http://example.com", - ), + config_overrides_context(CHECK_UPDATE_URL="http://example.com"), patch.object( system_module.httpx, "get", @@ -113,11 +102,7 @@ class TestCheckVersionUpdate: } with ( - patch.object( - system_module.dify_config, - "CHECK_UPDATE_URL", - "http://example.com", - ), + config_overrides_context(CHECK_UPDATE_URL="http://example.com"), patch.object( system_module.httpx, "get", diff --git a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py index ea0fc9c2dc8..0a33e67163b 100644 --- a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py +++ b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py @@ -6,7 +6,6 @@ import pytest from flask import Flask from werkzeug.exceptions import Conflict, Forbidden, NotFound -from configs import dify_config from controllers.console import flask_admission, workflow_run_archive from controllers.console.workflow_run_archive import ( WorkflowRunArchiveDownloadApi, @@ -37,6 +36,7 @@ _ENDPOINTS = [ WorkflowRunArchiveDownloadApi.get, WorkflowRunArchiveDownloadFileApi.get, ] +from tests.unit_tests.config_override import apply_config_overrides def _account(role: TenantAccountRole) -> Account: @@ -82,7 +82,7 @@ def test_workflow_run_archive_endpoints_reject_non_manager_when_rbac_is_disabled method, ) -> None: account = _account(TenantAccountRole.NORMAL) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + apply_config_overrides(monkeypatch, RBAC_ENABLED=False) monkeypatch.setattr( flask_admission, "current_account_with_tenant", @@ -108,7 +108,7 @@ def test_workflow_run_archive_endpoints_are_hidden_outside_cloud( method, args: tuple[object, ...], ) -> None: - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) app = Flask(__name__) with app.test_request_context(), pytest.raises(NotFound): @@ -119,7 +119,7 @@ def test_workflow_run_archive_endpoint_allows_admitted_role_when_rbac_is_enabled monkeypatch: pytest.MonkeyPatch, ) -> None: account = _account(TenantAccountRole.NORMAL) - monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) account_with_tenant = AccountWithTenant(account=account, tenant_id="tenant-1") monkeypatch.setattr(flask_admission, "current_account_with_tenant", lambda: account_with_tenant) monkeypatch.setattr("controllers.console.wraps.current_account_with_tenant", lambda: account_with_tenant) diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index ae2002850c8..d93bcfa296d 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -4,17 +4,23 @@ from unittest.mock import MagicMock, patch from uuid import NAMESPACE_URL, uuid5 import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from controllers.console.auth.error import InvalidTokenError from controllers.console.error import EducationActivateLimitError, EducationVerifyLimitError, EmailDomainSuspendedError from controllers.console.workspace.account import ( AccountDeleteUpdateFeedbackApi, + AccountDeletionFeedbackPayload, ChangeEmailCheckApi, ChangeEmailResetApi, + ChangeEmailResetPayload, ChangeEmailSendEmailApi, + ChangeEmailSendPayload, + ChangeEmailValidityPayload, CheckEmailUnique, + CheckEmailUniquePayload, + EducationActivatePayload, EducationApi, EducationVerifyApi, ) @@ -127,7 +133,7 @@ class TestEducationApi: ): api = EducationApi() method = inspect.unwrap(api.post) - result = method(api, request_context) + result = method(api, EducationActivatePayload.model_validate(request.get_json() or {}), request_context) assert result == {"message": "success"} education.activate.assert_called_once_with( @@ -181,7 +187,9 @@ class TestEducationApi: ): api = EducationApi() with pytest.raises(EducationActivateLimitError): - inspect.unwrap(api.post)(api, request_context) + inspect.unwrap(api.post)( + api, EducationActivatePayload.model_validate(request.get_json() or {}), request_context + ) def _change_email_context(account_id: str = "acc") -> RequestContext: @@ -218,7 +226,9 @@ class TestChangeEmailControllers: ), ): api = ChangeEmailSendEmailApi() - response = inspect.unwrap(api.post)(api, context) + response = inspect.unwrap(api.post)( + api, ChangeEmailSendPayload.model_validate(request.get_json() or {}), context + ) assert response == {"result": "success", "data": "change-token"} change_email.send_code.assert_called_once_with( @@ -248,7 +258,7 @@ class TestChangeEmailControllers: api = ChangeEmailSendEmailApi() method = inspect.unwrap(api.post) with pytest.raises(InvalidTokenError): - method(api, _change_email_context()) + method(api, ChangeEmailSendPayload.model_validate(request.get_json() or {}), _change_email_context()) def test_validity_serializes_promoted_token(self, app: Flask): change_email = MagicMock() @@ -270,7 +280,9 @@ class TestChangeEmailControllers: ), ): api = ChangeEmailCheckApi() - response = inspect.unwrap(api.post)(api, context) + response = inspect.unwrap(api.post)( + api, ChangeEmailValidityPayload.model_validate(request.get_json() or {}), context + ) assert response == {"is_valid": True, "email": "new@example.com", "token": "verified-token"} change_email.verify_code.assert_called_once_with( @@ -298,7 +310,9 @@ class TestChangeEmailControllers: ), ): api = ChangeEmailResetApi() - response = inspect.unwrap(api.post)(api, context) + response = inspect.unwrap(api.post)( + api, ChangeEmailResetPayload.model_validate(request.get_json() or {}), context + ) assert response["email"] == "new@example.com" change_email.reset.assert_called_once_with( @@ -324,7 +338,9 @@ class TestChangeEmailControllers: ): api = ChangeEmailResetApi() with pytest.raises(EmailDomainSuspendedError): - inspect.unwrap(api.post)(api, _change_email_context()) + inspect.unwrap(api.post)( + api, ChangeEmailResetPayload.model_validate(request.get_json() or {}), _change_email_context() + ) class TestAccountServiceSendChangeEmailEmail: @@ -433,7 +449,7 @@ class TestAccountDeletionFeedback: ): api = AccountDeleteUpdateFeedbackApi() method = inspect.unwrap(api.post) - response = method(api) + response = method(api, AccountDeletionFeedbackPayload.model_validate(request.get_json() or {})) assert response == {"result": "success"} deletion_feedback.submit.assert_called_once_with(email="User@Example.com", feedback="test") @@ -455,7 +471,7 @@ class TestCheckEmailUnique: ), ): api = CheckEmailUnique() - response = inspect.unwrap(api.post)(api) + response = inspect.unwrap(api.post)(api, CheckEmailUniquePayload.model_validate(request.get_json() or {})) assert response == {"result": "success"} change_email.ensure_available.assert_called_once_with("Case@Test.com") @@ -477,7 +493,7 @@ class TestCheckEmailUnique: ): api = CheckEmailUnique() with pytest.raises(EmailDomainSuspendedError): - inspect.unwrap(api.post)(api) + inspect.unwrap(api.post)(api, CheckEmailUniquePayload.model_validate(request.get_json() or {})) @pytest.mark.parametrize( diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 72787392156..f8107192690 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -45,6 +45,7 @@ from models import Account, DifySetup from models.account import AccountStatus, TenantAccountRole from models.dataset import Dataset, RateLimitLog from services.entities.feature_entities import LicenseStatus +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture(autouse=True) @@ -195,6 +196,64 @@ class TestCurrentContextInjection: login_required.assert_called_once() account_initialization_required.assert_called_once() + def test_console_email_registration_admission_checks_features_once(self): + features = SimpleNamespace(enable_email_password_login=True, is_allow_register=True) + with ( + patch( + "controllers.console.flask_admission.setup_required", side_effect=lambda view: view + ) as setup_required, + patch( + "controllers.console.flask_admission.FeatureService.get_system_features", + return_value=features, + ) as get_system_features, + ): + + class Handler: + @flask_admission.console_email_registration_admission + def post(self): + return "ok" + + with Flask(__name__).test_request_context(): + result = Handler().post() + + assert result == "ok" + setup_required.assert_called_once() + get_system_features.assert_called_once_with() + + @pytest.mark.parametrize( + ("enable_email_password_login", "is_allow_register"), + [ + pytest.param(False, True, id="password-login-disabled"), + pytest.param(True, False, id="registration-disabled"), + ], + ) + def test_console_email_registration_admission_rejects_disabled_features( + self, + enable_email_password_login: bool, + is_allow_register: bool, + ) -> None: + features = SimpleNamespace( + enable_email_password_login=enable_email_password_login, + is_allow_register=is_allow_register, + ) + with ( + patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), + patch( + "controllers.console.flask_admission.FeatureService.get_system_features", + return_value=features, + ), + ): + + class Handler: + @flask_admission.console_email_registration_admission + def post(self): + return "ok" + + with Flask(__name__).test_request_context(), pytest.raises(HTTPException) as exc_info: + Handler().post() + + assert exc_info.value.code == 403 + def test_console_account_admission_preserves_route_kwarg_named_request_context(self): current_user = make_account() @@ -228,10 +287,7 @@ class TestCurrentContextInjection: return request_context with ( - patch( - "controllers.console.flask_admission.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), Flask(__name__).test_request_context(), pytest.raises(HTTPException) as exc_info, ): @@ -247,7 +303,7 @@ class TestCurrentContextInjection: patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.login_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view), - patch("controllers.console.flask_admission.dify_config.RBAC_ENABLED", False), + config_overrides_context(RBAC_ENABLED=False), patch( "controllers.console.flask_admission.current_account_with_tenant", return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"), @@ -273,7 +329,7 @@ class TestCurrentContextInjection: patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.login_required", side_effect=lambda view: view), patch("controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view), - patch("controllers.console.flask_admission.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "controllers.console.flask_admission.current_account_with_tenant", return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"), diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index d0178451eb2..a0487e7b329 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -19,21 +19,32 @@ from controllers.console.auth.error import ( from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError from controllers.console.workspace.account import ( AccountAvatarApi, + AccountAvatarPayload, AccountAvatarQuery, AccountDeleteApi, + AccountDeletePayload, AccountDeleteVerifyApi, AccountInitApi, + AccountInitPayload, AccountIntegrateApi, AccountInterfaceLanguageApi, + AccountInterfaceLanguagePayload, AccountInterfaceThemeApi, + AccountInterfaceThemePayload, AccountNameApi, + AccountNamePayload, AccountPasswordApi, + AccountPasswordPayload, AccountProfileApi, AccountProfilePatchPayload, AccountTimezoneApi, + AccountTimezonePayload, ChangeEmailCheckApi, ChangeEmailResetApi, + ChangeEmailResetPayload, + ChangeEmailValidityPayload, CheckEmailUnique, + CheckEmailUniquePayload, ) from controllers.console.workspace.error import ( AccountAlreadyInitedError, @@ -59,6 +70,7 @@ from services.account_errors import ( MissingInvitationCodeError, ) from services.entities.account_entities import AccountIntegrationStatus, AccountProfileChanges +from tests.unit_tests.config_override import config_overrides_context def make_account(account_id: str = "u1", *, status: AccountStatus = AccountStatus.ACTIVE) -> Account: @@ -119,7 +131,7 @@ class TestAccountInitApi: return_value=SimpleNamespace(accounts=SimpleNamespace(initialization=initialization)), ), ): - resp = method(api, request_context) + resp = method(api, AccountInitPayload.model_validate(payload), request_context) assert resp["result"] == "success" initialization.initialize.assert_called_once_with( @@ -151,7 +163,7 @@ class TestAccountInitApi: ), ): with pytest.raises(AccountAlreadyInitedError): - method(api, request_context) + method(api, AccountInitPayload.model_validate(payload), request_context) def test_init_missing_invitation_code_is_mapped(self, app: Flask): api = AccountInitApi() @@ -174,7 +186,7 @@ class TestAccountInitApi: ), ): with pytest.raises(MissingInvitationCodeRequestError) as exc_info: - method(api, request_context) + method(api, AccountInitPayload.model_validate(payload), request_context) assert exc_info.value.data == { "code": "missing_invitation_code", @@ -212,25 +224,27 @@ class TestAccountProfileApi: class TestAccountUpdateApis: @pytest.mark.parametrize( - ("api_cls", "payload", "expected_changes"), + ("api_cls", "payload_model", "payload", "expected_changes"), [ - (AccountNameApi, {"name": "test"}, AccountProfileChanges(name="test")), - (AccountAvatarApi, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")), + (AccountNameApi, AccountNamePayload, {"name": "test"}, AccountProfileChanges(name="test")), + (AccountAvatarApi, AccountAvatarPayload, {"avatar": "img.png"}, AccountProfileChanges(avatar="img.png")), ( AccountInterfaceLanguageApi, + AccountInterfaceLanguagePayload, {"interface_language": "en-US"}, AccountProfileChanges(interface_language="en-US"), ), ( AccountInterfaceThemeApi, + AccountInterfaceThemePayload, {"interface_theme": "dark"}, AccountProfileChanges(interface_theme="dark"), ), - (AccountTimezoneApi, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")), + (AccountTimezoneApi, AccountTimezonePayload, {"timezone": "UTC"}, AccountProfileChanges(timezone="UTC")), ], ) def test_deprecated_update_routes_delegate_to_profile_service( - self, app: Flask, api_cls, payload, expected_changes: AccountProfileChanges + self, app: Flask, api_cls, payload_model, payload, expected_changes: AccountProfileChanges ): api = api_cls() method = inspect.unwrap(api.post) @@ -251,7 +265,7 @@ class TestAccountUpdateApis: return_value=SimpleNamespace(accounts=SimpleNamespace(profile=profile)), ), ): - result = method(api, request_context) + result = method(api, payload_model.model_validate(payload), request_context) assert result["id"] == user.id profile.update.assert_called_once_with(request_context, expected_changes) @@ -409,7 +423,7 @@ class TestAccountAvatarApiGet: with ( app.test_request_context("/account/avatar"), patch("controllers.console.wraps._is_setup_completed", return_value=True), - patch("libs.login.dify_config.LOGIN_DISABLED", True), + config_overrides_context(LOGIN_DISABLED=True), patch( "controllers.console.wraps.current_account_with_tenant", return_value=(account, "workspace-1"), @@ -425,6 +439,30 @@ class TestAccountAvatarApiGet: assert exc_info.value.code == 422 +class TestConvertedPostDecorator: + def test_rejects_an_invalid_body_through_the_decorator(self, app: Flask): + """The decorator validates the JSON body before the view runs, for the POST handlers too.""" + account = make_account() + + with ( + app.test_request_context("/account/name", method="POST", json={}), + patch("controllers.console.wraps._is_setup_completed", return_value=True), + config_overrides_context(LOGIN_DISABLED=True), + patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(account, "workspace-1"), + ), + patch( + "controllers.console.flask_admission.current_account_with_tenant", + return_value=SimpleNamespace(account=account, tenant_id="workspace-1"), + ), + ): + with pytest.raises(UnprocessableEntity) as exc_info: + AccountNameApi().post() + + assert exc_info.value.code == 422 + + class TestAccountPasswordApi: def test_password_success(self, app: Flask): api = AccountPasswordApi() @@ -453,7 +491,7 @@ class TestAccountPasswordApi: return_value=SimpleNamespace(accounts=SimpleNamespace(password=password)), ), ): - result = method(api, request_context) + result = method(api, AccountPasswordPayload.model_validate(payload), request_context) assert result["id"] == user.id password.change.assert_called_once_with( @@ -489,7 +527,7 @@ class TestAccountPasswordApi: ), ): with pytest.raises(CurrentPasswordIncorrectError): - method(api, request_context) + method(api, AccountPasswordPayload.model_validate(payload), request_context) def test_password_policy_error_is_mapped(self, app: Flask): api = AccountPasswordApi() @@ -518,7 +556,7 @@ class TestAccountPasswordApi: ), ): with pytest.raises(InvalidAccountPasswordRequestError) as exc_info: - method(api, request_context) + method(api, AccountPasswordPayload.model_validate(payload), request_context) assert exc_info.value.data == { "code": "invalid_account_password", @@ -609,7 +647,7 @@ class TestAccountDeleteApi: ), ): with pytest.raises(InvalidAccountDeletionCodeError): - method(api, request_context) + method(api, AccountDeletePayload.model_validate(payload), request_context) def test_delete_verify_maps_rate_limit(self, app: Flask): api = AccountDeleteVerifyApi() @@ -652,7 +690,7 @@ class TestAccountDeleteApi: return_value=SimpleNamespace(accounts=SimpleNamespace(deletion=deletion)), ), ): - result = method(api, request_context) + result = method(api, AccountDeletePayload.model_validate(payload), request_context) assert result["result"] == "success" deletion.request_deletion.assert_called_once_with(request_context, token="token", code="123456") @@ -688,7 +726,7 @@ class TestChangeEmailApis: ), ): with pytest.raises(EmailCodeError): - method(api, request_context) + method(api, ChangeEmailValidityPayload.model_validate(payload), request_context) def test_reset_email_already_used(self, app: Flask): api = ChangeEmailResetApi() @@ -719,7 +757,7 @@ class TestChangeEmailApis: ), ): with pytest.raises(EmailAlreadyInUseError): - method(api, request_context) + method(api, ChangeEmailResetPayload.model_validate(payload), request_context) class TestCheckEmailUniqueApi: @@ -743,7 +781,7 @@ class TestCheckEmailUniqueApi: return_value=SimpleNamespace(accounts=SimpleNamespace(change_email=change_email)), ), ): - result = method(api) + result = method(api, CheckEmailUniquePayload.model_validate(payload)) assert result["result"] == "success" @@ -769,7 +807,7 @@ class TestCheckEmailUniqueApi: ), ): with pytest.raises(AccountInFreezeError): - method(api) + method(api, CheckEmailUniquePayload.model_validate(payload)) def test_email_domain_is_suspended(self, app: Flask): api = CheckEmailUnique() @@ -793,4 +831,4 @@ class TestCheckEmailUniqueApi: ), ): with pytest.raises(EmailDomainSuspendedError): - method(api) + method(api, CheckEmailUniquePayload.model_validate(payload)) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py index 93a6133007c..a4f14e5255d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py @@ -38,6 +38,7 @@ from services.entities.model_provider_entities import ( SystemConfigurationResponse, ) from services.workspace_service import EffectiveCreditPool +from tests.unit_tests.config_override import config_overrides_context VALID_UUID = "123e4567-e89b-12d3-a456-426614174000" INVALID_UUID = "123" @@ -603,9 +604,11 @@ class TestModelProviderPaymentCheckoutUrlApi: with ( app.test_request_context("/"), - patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), - patch.object(dify_config, "LOGIN_DISABLED", True), - patch.object(dify_config, "RBAC_ENABLED", False), + config_overrides_context( + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + LOGIN_DISABLED=True, + RBAC_ENABLED=False, + ), patch( "controllers.console.workspace.model_providers.BillingService.get_model_provider_payment_link", ) as get_model_provider_payment_link, diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index 88e9487f5dc..ea0f79a851c 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -82,6 +82,7 @@ from models.account import ( TenantPluginDebugPermission, TenantPluginInstallPermission, ) +from tests.unit_tests.config_override import config_overrides_context def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]: @@ -653,7 +654,7 @@ class TestPluginUploadFromPkgApi: with ( app.test_request_context("/", data=data, content_type="multipart/form-data"), - patch("controllers.console.workspace.plugin.dify_config.PLUGIN_MAX_PACKAGE_SIZE", 0), + config_overrides_context(PLUGIN_MAX_PACKAGE_SIZE=0), patch("controllers.console.workspace.plugin.PluginService.upload_pkg") as upload_pkg_mock, ): with pytest.raises(ValueError) as exc_info: @@ -937,7 +938,7 @@ class TestPluginUploadFromBundleApi: data={"bundle": file}, content_type="multipart/form-data", ), - patch("controllers.console.workspace.plugin.dify_config.PLUGIN_MAX_BUNDLE_SIZE", 0), + config_overrides_context(PLUGIN_MAX_BUNDLE_SIZE=0), patch("controllers.console.workspace.plugin.PluginService.upload_bundle") as upload_bundle_mock, ): with pytest.raises(ValueError) as exc_info: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 9c399e18add..6a7d95d0f0a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -45,6 +45,7 @@ from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfi from repositories.workspace_query_repository import WorkspaceQueryRepository from services import workspace_plan_gateway from services.workspace_query_service import WorkspaceQueryService, WorkspaceRecord +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -485,7 +486,7 @@ class TestCustomConfigWorkspaceApi: with ( app.test_request_context("/workspaces/custom-config"), - patch("controllers.console.workspace.workspace.dify_config.FILES_URL", "https://files.example.com"), + config_overrides_context(FILES_URL="https://files.example.com"), ): result = method(api, workspace_session, tenant.id) diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py index 102c4ab4b53..3f8a6efde6b 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py @@ -35,6 +35,7 @@ from controllers.inner_api.plugin.plugin import ( ) from core.workflow.file_reference import build_file_reference from models import Account, Tenant +from tests.unit_tests.config_override import apply_config_overrides def _tenant() -> Tenant: @@ -280,7 +281,7 @@ class TestPluginUploadFileRequestApi: """Test that post() generates a signed URL and returns it""" # Arrange mock_get_uri.return_value = "/files/upload/for-plugin?sign=1" - monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001") + apply_config_overrides(monkeypatch, INTERNAL_FILES_URL="http://api:5001") tenant = _tenant() user = _user() mock_payload = MagicMock() @@ -350,8 +351,11 @@ class TestPluginDownloadFileRequestApi: size=123, download_uri="/files/tools/report.pdf?sign=1", ) - monkeypatch.setattr(plugin_module.dify_config, "FILES_URL", "https://files.example.com") - monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001") + apply_config_overrides( + monkeypatch, + FILES_URL="https://files.example.com", + INTERNAL_FILES_URL="http://api:5001", + ) mock_payload = MagicMock() mock_payload.tenant_id = tenant.id mock_payload.user_id = "user-id" diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_files.py b/api/tests/unit_tests/controllers/inner_api/test_agent_files.py index a4874ae2077..f5bb418d76a 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_files.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_files.py @@ -15,6 +15,7 @@ from controllers.inner_api.agent.files import ( from core.workflow.file_reference import build_file_reference from models.account import Account, Tenant from services.file_request_service import DownloadFileRequestResult +from tests.unit_tests.config_override import apply_config_overrides MODULE = "controllers.inner_api.agent.files" @@ -132,7 +133,7 @@ def test_download_request_binds_frontend_url( "file": {"transfer_method": "tool_file", "reference": reference}, "for_frontend": True, } - monkeypatch.setattr(f"{MODULE}.dify_config.FILES_URL", "https://files.example.com") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example.com") session = unbound_session with app.test_request_context("/", method="POST", json=payload): with ( diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py b/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py index 15160f8e749..f37e1acd971 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_llm.py @@ -15,6 +15,7 @@ from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResul from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, UserPromptMessage from services.agent_llm_inner_service import AgentLLMInnerServiceError, PreparedAgentLLMInvocation from services.entities.agent_llm_inner import AgentLLMInvokeRequest +from tests.unit_tests.config_override import config_overrides_context def _payload() -> dict[str, object]: @@ -44,8 +45,7 @@ def _payload() -> dict[str, object]: @contextmanager def _agent_inner_auth() -> Generator[None]: with ( - patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), - patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + config_overrides_context(PLUGIN_DAEMON_KEY="plugin-daemon-key", INNER_API_KEY_FOR_PLUGIN="inner-key"), ): yield diff --git a/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py b/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py index 2a92ecbbc10..186615f3d90 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py +++ b/api/tests/unit_tests/controllers/inner_api/test_agent_tools.py @@ -9,6 +9,7 @@ from flask import Flask from controllers.inner_api import bp as inner_api_bp from services.entities.agent_tool_inner import AgentToolInvokeResponse from services.errors.agent_tool_inner import AgentToolInnerServiceError +from tests.unit_tests.config_override import config_overrides_context def _headers(api_key: str | None = "inner-key") -> dict[str, str]: @@ -48,8 +49,7 @@ def _payload() -> dict[str, object]: @contextmanager def _agent_inner_auth() -> Generator[None]: with ( - patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), - patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + config_overrides_context(PLUGIN_DAEMON_KEY="plugin-daemon-key", INNER_API_KEY_FOR_PLUGIN="inner-key"), ): yield diff --git a/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py index 00eab81cbe6..805385b09b2 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py +++ b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py @@ -16,6 +16,7 @@ from services.errors.knowledge_retrieval import ( InnerKnowledgeRetrieveAppNotFoundError, InnerKnowledgeRetrieveDatasetTenantMismatchError, ) +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -58,8 +59,7 @@ def _payload() -> dict[str, object]: @contextmanager def _plugin_inner_auth() -> Iterator[None]: with ( - patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"), - patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"), + config_overrides_context(PLUGIN_DAEMON_KEY="plugin-daemon-key", INNER_API_KEY_FOR_PLUGIN="inner-key"), ): yield diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py index 41d4416efc5..e1b7a4f3cd3 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -17,6 +17,7 @@ from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode +from tests.unit_tests.config_override import config_overrides_context def test_account_pipeline_is_auth_pipeline(): @@ -163,10 +164,7 @@ def _selected_webapp_steps(*, scope, app_access_mode): features.webapp_auth.enabled = True selected = [] with ( - patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.ENTERPRISE, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), patch("controllers.openapi.auth.conditions.FeatureService.get_system_features", return_value=features), ): for step in account_pipeline._auth: diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py index b9cd877f0bf..a1f6b26c9ca 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py @@ -24,6 +24,7 @@ from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode +from tests.unit_tests.config_override import config_overrides_context def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None, **kwargs): @@ -117,29 +118,20 @@ def test_path_has_app_id_false(): def test_edition_community(): - with patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): assert EDITION_COMMUNITY(_ctx()) is True assert EDITION_ENTERPRISE(_ctx()) is False assert EDITION_CLOUD(_ctx()) is False def test_edition_enterprise(): - with patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.ENTERPRISE, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE): assert EDITION_ENTERPRISE(_ctx()) is True assert EDITION_COMMUNITY(_ctx()) is False def test_edition_cloud(): - with patch( - "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.CLOUD, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): assert EDITION_CLOUD(_ctx()) is True diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py index a9f5df5aae6..f483c80eb38 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py @@ -9,6 +9,7 @@ from controllers.openapi.auth.data import AuthData from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType +from tests.unit_tests.config_override import config_overrides_context def _make_identity( @@ -76,10 +77,7 @@ def test_guard_edition_gate_returns_404(app): router = _make_router() with app.test_request_context("/test"): - with patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): @router.guard(scope=Scope.FULL, edition=frozenset({DeploymentEdition.ENTERPRISE})) def view(*, auth_data): @@ -97,10 +95,7 @@ def test_guard_token_type_gate_returns_403(app): patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, patch("controllers.openapi.auth.pipeline.emit_wrong_surface"), - patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), ): identity = _fake_identity() identity.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -121,10 +116,7 @@ def test_guard_unregistered_token_type_returns_403(app): with ( patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), ): identity = _fake_identity() identity.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -213,10 +205,7 @@ def test_router_rejects_token_type_on_wrong_edition(app): with ( patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch( - "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), ): identity = _make_identity(token_type=TokenType.OAUTH_EXTERNAL_SSO) mock_auth.return_value.authenticate.return_value = identity diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py index d9c468d0a4e..d2787a57591 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_rate_limit.py @@ -8,8 +8,12 @@ import pytest from werkzeug.exceptions import TooManyRequests from controllers.openapi.app_run import _translate_service_errors +from controllers.service_api.app.error import TriggerWorkflowServiceModeUnavailableError from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.errors.error import AppInvokeQuotaExceededError +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError @@ -27,3 +31,11 @@ def test_translate_maps_workflow_quota_to_rate_limit_error(): raise InvokeRateLimitError("workflow quota exhausted") assert exc.value.error_code == "rate_limit_error" assert exc.value.code == 429 + + +def test_translate_maps_trigger_workflow_to_stable_unavailable_error(): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc: + with _translate_service_errors(): + raise TriggerWorkflowServiceModeUnavailableServiceError() + assert exc.value.error_code == "trigger_workflow_service_mode_unavailable" + assert exc.value.code == 403 diff --git a/api/tests/unit_tests/controllers/openapi/test_meta_version.py b/api/tests/unit_tests/controllers/openapi/test_meta_version.py index 3da3c4fca21..9befa1c9551 100644 --- a/api/tests/unit_tests/controllers/openapi/test_meta_version.py +++ b/api/tests/unit_tests/controllers/openapi/test_meta_version.py @@ -5,6 +5,7 @@ from __future__ import annotations import pytest from enums import DeploymentEdition +from tests.unit_tests.config_override import apply_config_overrides def test_version_endpoint_returns_200_without_auth(openapi_app): @@ -35,9 +36,7 @@ def test_version_endpoint_ignores_bearer_header(openapi_app): def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pytest.MonkeyPatch): - from configs import dify_config - - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) client = openapi_app.test_client() response = client.get("/openapi/v1/_version") @@ -47,9 +46,7 @@ def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pyte def test_version_endpoint_reflects_enterprise_edition(openapi_app, monkeypatch: pytest.MonkeyPatch): - from configs import dify_config - - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) client = openapi_app.test_client() response = client.get("/openapi/v1/_version") diff --git a/api/tests/unit_tests/controllers/service_api/app/test_audio.py b/api/tests/unit_tests/controllers/service_api/app/test_audio.py index 091c129c874..9e51ffea837 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_audio.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_audio.py @@ -13,7 +13,7 @@ from inspect import unwrap from unittest.mock import Mock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from werkzeug.exceptions import InternalServerError @@ -285,7 +285,8 @@ class TestTextApi: method="POST", json={"text": "hello", "voice": "v"}, ): - response = handler(api, app_model=app_model, end_user=end_user) + payload = TextToAudioPayload.model_validate(request.get_json() or {}) + response = handler(api, payload, app_model=app_model, end_user=end_user) assert response == {"audio": "ok"} @@ -308,7 +309,8 @@ class TestTextApi: method="POST", json={"text": "hello", "message_id": "message-1"}, ): - response = handler(api, app_model=app_model, end_user=end_user) + payload = TextToAudioPayload.model_validate(request.get_json() or {}) + response = handler(api, payload, app_model=app_model, end_user=end_user) assert response == {"audio": "ok"} assert calls["message_ref"] == MessageRef(AppRef("tenant-1", "a1"), "message-1", end_user_id="end-user-1") @@ -324,5 +326,6 @@ class TestTextApi: end_user = _end_user(end_user_id="end-user-1", external_user_id="ext") with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}): + payload = TextToAudioPayload.model_validate(request.get_json() or {}) with pytest.raises(ProviderQuotaExceededError): - handler(api, app_model=app_model, end_user=end_user) + handler(api, payload, app_model=app_model, end_user=end_user) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index 39c986e3ca2..a83e8b9f734 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -54,6 +54,7 @@ from services.conversation_service import ConversationService from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.conversation import ConversationNotExistsError from services.errors.llm import InvokeRateLimitError +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -556,7 +557,7 @@ class TestChatApiController: self, app: Flask, monkeypatch: pytest.MonkeyPatch, orm_session: Session ) -> None: completion_module = sys.modules["controllers.service_api.app.completion"] - monkeypatch.setattr(completion_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock() @@ -602,7 +603,7 @@ class TestChatApiController: workflow_id: str | None, ) -> None: completion_module = sys.modules["controllers.service_api.app.completion"] - monkeypatch.setattr(completion_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=deployment_edition) billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}}) generate = Mock(return_value={"result": "ok"}) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 926d6835149..e163ebf4be5 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -21,7 +21,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound @@ -625,9 +625,11 @@ class TestConversationRenameApiController: method="POST", json={"auto_generate": True}, ): + payload = ConversationRenamePayload.model_validate(request.get_json() or {}) with pytest.raises(NotFound): handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -736,9 +738,11 @@ class TestConversationVariableDetailApiController: method="PUT", json={"value": "x"}, ): + payload = ConversationVariableUpdatePayload.model_validate(request.get_json() or {}) with pytest.raises(BadRequest): handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -762,9 +766,11 @@ class TestConversationVariableDetailApiController: method="PUT", json={"value": "x"}, ): + payload = ConversationVariableUpdatePayload.model_validate(request.get_json() or {}) with pytest.raises(NotFound): handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", @@ -796,8 +802,10 @@ class TestConversationVariableDetailApiController: method="PUT", json={"value": 1}, ): + payload = ConversationVariableUpdatePayload.model_validate(request.get_json() or {}) result = handler( api, + payload, app_model=app_model, end_user=end_user, conversation_id="00000000-0000-0000-0000-000000000001", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index 55a130d8b09..d0d77557cad 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -50,6 +50,7 @@ from repositories.api_workflow_node_execution_repository import WorkflowNodeExec from repositories.entities.workflow_pause import WorkflowPauseEntity from services.app_generate_service import AppGenerateService from services.workflow_event_snapshot_service import _build_snapshot_events +from tests.unit_tests.config_override import apply_config_overrides class _DummyRateLimit: @@ -380,7 +381,7 @@ class TestHitlServiceApi: monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, ) -> None: - monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr(ags_module, "RateLimit", _DummyRateLimit) workflow = MagicMock() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index f91a9ca35f2..9dd436d8795 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -28,7 +28,11 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, NotFound -from controllers.service_api.app.error import NotWorkflowAppError, WorkflowVersionExecutionNotAllowedError +from controllers.service_api.app.error import ( + NotWorkflowAppError, + TriggerWorkflowServiceModeUnavailableError, + WorkflowVersionExecutionNotAllowedError, +) from controllers.service_api.app.workflow import ( AppQueueManager, GraphEngineManager, @@ -51,7 +55,13 @@ from models.model import App, AppMode, EndUser from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType from services.app_generate_service import AppGenerateService from services.billing_service import BillingService -from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError +from services.errors.app import ( + IsDraftWorkflowError, + WorkflowNotFoundError, +) +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) from services.errors.llm import InvokeRateLimitError from services.workflow_app_service import WorkflowAppService @@ -582,6 +592,32 @@ class TestWorkflowRunApi: with pytest.raises(InvokeRateLimitHttpError): handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) + def test_trigger_workflow_returns_stable_unavailable_error( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + ) -> None: + monkeypatch.setattr( + AppGenerateService, + "generate", + Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()), + ) + api = WorkflowRunApi() + handler = unwrap(api.post) + + with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info: + handler( + api, + session=sqlite_session, + app_model=_make_app_model(), + end_user=_make_end_user(), + ) + + assert exc_info.value.code == 403 + assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable" + def test_sandbox_billing_does_not_gate_default_workflow_run( self, app: Flask, @@ -614,6 +650,33 @@ class TestWorkflowRunApi: class TestWorkflowRunByIdApi: + def test_trigger_workflow_version_returns_stable_unavailable_error( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + ) -> None: + monkeypatch.setattr( + AppGenerateService, + "generate", + Mock(side_effect=TriggerWorkflowServiceModeUnavailableServiceError()), + ) + api = WorkflowRunByIdApi() + handler = unwrap(api.post) + + with app.test_request_context("/workflows/w1/run", method="POST", json={"inputs": {}}): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info: + handler( + api, + session=sqlite_session, + app_model=_make_app_model(), + end_user=_make_end_user(), + workflow_id=str(uuid.uuid4()), + ) + + assert exc_info.value.code == 403 + assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable" + def test_rejects_sandbox_plan_with_upgrade_error( self, app: Flask, diff --git a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py index df1c9ff00d2..4a87b4a8ff6 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py @@ -506,7 +506,10 @@ class TestDatasourceNodeRunApiPost: The source asserts ``isinstance(current_user, Account)`` and delegates to ``RagPipelineService`` and ``PipelineGenerator``, so we patch those plus - ``current_user`` and ``service_api_ns``. + ``current_user``. ``post`` is wrapped in ``@model_validate``, which parses + the JSON request body live, so payloads are supplied via + ``test_request_context(json=...)`` and validation runs before the dataset + ownership guard. """ @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.helper") @@ -516,10 +519,8 @@ class TestDatasourceNodeRunApiPost: new_callable=lambda: Account(name="Test Account", email="test@example.com"), ) @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") - @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") def test_post_success( self, - mock_ns, mock_svc_cls, current_account, mock_gen, @@ -534,12 +535,6 @@ class TestDatasourceNodeRunApiPost: _persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) - mock_ns.payload = { - "inputs": {"url": "https://example.com"}, - "datasource_type": "online_document", - "is_published": True, - } - pipeline = _persist_pipeline(sqlite_session, tenant_id=tenant_id) mock_svc_instance = Mock() mock_svc_instance.get_pipeline.return_value = pipeline @@ -549,7 +544,15 @@ class TestDatasourceNodeRunApiPost: mock_gen.convert_to_event_stream.return_value = iter(["stream_event"]) mock_helper.compact_generate_response.return_value = {"result": "ok"} - with app.test_request_context("/datasets/test/pipeline/datasource/nodes/node_abc/run", method="POST"): + with app.test_request_context( + "/datasets/test/pipeline/datasource/nodes/node_abc/run", + method="POST", + json={ + "inputs": {"url": "https://example.com"}, + "datasource_type": "online_document", + "is_published": True, + }, + ): api = DatasourceNodeRunApi() response = api.post(tenant_id=tenant_id, dataset_id=dataset_id, node_id=node_id) @@ -561,7 +564,13 @@ class TestDatasourceNodeRunApiPost: def test_post_not_found(self, app: Flask, sqlite_session: Session): """Test NotFound when dataset check fails.""" - with app.test_request_context("/datasets/test/pipeline/datasource/nodes/n1/run", method="POST"): + # `@model_validate` parses the body before the ownership guard, so a + # valid payload is required to reach the NotFound branch. + with app.test_request_context( + "/datasets/test/pipeline/datasource/nodes/n1/run", + method="POST", + json={"inputs": {}, "datasource_type": "online_document", "is_published": True}, + ): api = DatasourceNodeRunApi() with pytest.raises(NotFound): api.post(tenant_id=str(uuid.uuid4()), dataset_id=str(uuid.uuid4()), node_id="n1") @@ -570,19 +579,17 @@ class TestDatasourceNodeRunApiPost: "controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account", ) - @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_fails_when_current_user_not_account(self, mock_ns, app: Flask, sqlite_session: Session): + def test_post_fails_when_current_user_not_account(self, app: Flask, sqlite_session: Session): """Test AssertionError when current_user is not an Account instance.""" tenant_id = str(uuid.uuid4()) dataset_id = str(uuid.uuid4()) _persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) - mock_ns.payload = { - "inputs": {}, - "datasource_type": "local_file", - "is_published": True, - } - with app.test_request_context("/datasets/test/pipeline/datasource/nodes/n1/run", method="POST"): + with app.test_request_context( + "/datasets/test/pipeline/datasource/nodes/n1/run", + method="POST", + json={"inputs": {}, "datasource_type": "local_file", "is_published": True}, + ): api = DatasourceNodeRunApi() with pytest.raises(AssertionError): api.post(tenant_id=tenant_id, dataset_id=dataset_id, node_id="n1") diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py index fc17d166994..ade48fd2c03 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_apis.py @@ -10,7 +10,7 @@ from inspect import unwrap from unittest.mock import MagicMock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound @@ -462,7 +462,7 @@ class TestDatasetApiPatch: dataset: Dataset, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetApi + from controllers.service_api.dataset.dataset import DatasetApi, DatasetUpdatePayload dataset.name = "Updated Dataset" mock_dataset_svc.get_dataset.return_value = dataset @@ -481,8 +481,12 @@ class TestDatasetApiPatch: json=payload, ): api = DatasetApi() + # `patch` is wrapped in @model_validate, so the unwrapped view expects + # the validated model where the decorator would have injected it. + validated_payload = DatasetUpdatePayload.model_validate(request.get_json() or {}) response, status = unwrap(api.patch)( api, + validated_payload, controller_session, _=dataset.tenant_id, dataset_id=dataset.id, diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py index 1f20b74180f..55aae9be652 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_tag_apis.py @@ -10,7 +10,7 @@ from inspect import unwrap from unittest.mock import MagicMock, patch import pytest -from flask import Flask +from flask import Flask, request from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden @@ -109,7 +109,7 @@ class TestDatasetTagsApiPost: tenant: Tenant, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagsApi + from controllers.service_api.dataset.dataset import DatasetTagsApi, TagCreatePayload tag = make_tag(controller_session, tenant, account, id="tag-new", name="New Tag") mock_tag_svc.save_tags.return_value = tag @@ -120,7 +120,8 @@ class TestDatasetTagsApiPost: json={"name": "New Tag"}, ): api = DatasetTagsApi() - response, status = unwrap(api.post)(api, controller_session, _=None) + payload = TagCreatePayload.model_validate(request.get_json() or {}) + response, status = unwrap(api.post)(api, payload, controller_session, _=None) assert status == 200 assert response == {"id": "tag-new", "name": "New Tag", "type": "knowledge", "binding_count": "0"} @@ -155,7 +156,7 @@ class TestDatasetTagsApiPatch: tenant: Tenant, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagsApi + from controllers.service_api.dataset.dataset import DatasetTagsApi, TagUpdatePayload tag = make_tag(controller_session, tenant, account, id="tag-1", name="Updated Tag") mock_tag_svc.update_tags.return_value = tag @@ -168,7 +169,8 @@ class TestDatasetTagsApiPatch: json={"name": "Updated Tag", "tag_id": "tag-1"}, ): api = DatasetTagsApi() - response, status = unwrap(api.patch)(api, controller_session, _=None) + payload = TagUpdatePayload.model_validate(request.get_json() or {}) + response, status = unwrap(api.patch)(api, payload, controller_session, _=None) assert status == 200 assert response == {"id": "tag-1", "name": "Updated Tag", "type": "knowledge", "binding_count": "5"} @@ -206,7 +208,7 @@ class TestDatasetTagsApiDelete: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagsApi + from controllers.service_api.dataset.dataset import DatasetTagsApi, TagDeletePayload mock_tag_svc.delete_tag.return_value = None mock_service_api_ns.payload = {"tag_id": "tag-1"} @@ -217,7 +219,8 @@ class TestDatasetTagsApiDelete: json={"tag_id": "tag-1"}, ): api = DatasetTagsApi() - result = unwrap(api.delete)(api, controller_session, _=None) + payload = TagDeletePayload.model_validate(request.get_json() or {}) + result = unwrap(api.delete)(api, payload, controller_session, _=None) assert result == ("", 204) mock_tag_svc.delete_tag.assert_called_once_with("tag-1", controller_session, tag_type=TagType.KNOWLEDGE) @@ -263,7 +266,7 @@ class TestDatasetTagBindingApiPost: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagBindingApi + from controllers.service_api.dataset.dataset import DatasetTagBindingApi, TagBindingPayload mock_tag_svc.save_tag_binding.return_value = None @@ -273,7 +276,8 @@ class TestDatasetTagBindingApiPost: json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, ): api = DatasetTagBindingApi() - result = unwrap(api.post)(api, controller_session, _=None) + payload = TagBindingPayload.model_validate(request.get_json() or {}) + result = unwrap(api.post)(api, payload, controller_session, _=None) assert result == ("", 204) from services.tag_service import TagBindingCreatePayload @@ -309,7 +313,7 @@ class TestDatasetTagUnbindingApiPost: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi + from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi, TagUnbindingPayload mock_tag_svc.delete_tag_binding.return_value = None @@ -319,7 +323,8 @@ class TestDatasetTagUnbindingApiPost: json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, ): api = DatasetTagUnbindingApi() - result = unwrap(api.post)(api, controller_session, _=None) + payload = TagUnbindingPayload.model_validate(request.get_json() or {}) + result = unwrap(api.post)(api, payload, controller_session, _=None) assert result == ("", 204) from services.tag_service import TagBindingDeletePayload @@ -337,7 +342,7 @@ class TestDatasetTagUnbindingApiPost: app: Flask, controller_session: Session, ) -> None: - from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi + from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi, TagUnbindingPayload mock_tag_svc.delete_tag_binding.return_value = None @@ -347,7 +352,8 @@ class TestDatasetTagUnbindingApiPost: json={"tag_id": "tag-1", "target_id": "ds-1"}, ): api = DatasetTagUnbindingApi() - result = unwrap(api.post)(api, controller_session, _=None) + payload = TagUnbindingPayload.model_validate(request.get_json() or {}) + result = unwrap(api.post)(api, payload, controller_session, _=None) assert result == ("", 204) from services.tag_service import TagBindingDeletePayload diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py index 64e331ea072..e138a5b3c77 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py @@ -38,7 +38,7 @@ from controllers.service_api.dataset.metadata import ( from models.account import Account, Tenant from models.dataset import Dataset from models.enums import PermissionEnum -from services.entities.knowledge_entities.knowledge_entities import MetadataArgs +from services.entities.knowledge_entities.knowledge_entities import MetadataArgs, MetadataOperationData from services.errors.metadata import MetadataResourceNotFoundError @@ -537,7 +537,8 @@ class TestDocumentMetadataEditPost(_UsesSQLiteSession): @staticmethod def _call_post(api, session: Session, **kwargs): - return unwrap(api.post)(api, session, **kwargs) + metadata_args = MetadataOperationData.model_validate(request.get_json() or {}) + return unwrap(api.post)(api, metadata_args, session, **kwargs) @patch("controllers.service_api.dataset.metadata.MetadataService") @patch("controllers.service_api.dataset.metadata.DatasetService") diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 9b058fc889c..82c03952c8d 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -29,6 +29,7 @@ from models.account import TenantAccountRole from models.dataset import Dataset, RateLimitLog from models.enums import ApiTokenType from models.model import ApiToken, App, AppMode, IconType +from tests.unit_tests.config_override import config_overrides_context def _configure_current_app_mock(mock_current_app): @@ -346,7 +347,7 @@ class TestCloudEditionBillingResourceCheck: # Act with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), ): result = add_segment() @@ -376,7 +377,7 @@ class TestCloudEditionBillingResourceCheck: with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), pytest.raises(ServiceUnavailable) as exc_info, ): upload_document() @@ -406,7 +407,7 @@ class TestCloudEditionBillingResourceCheck: with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), ): result = upload_document() diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index af2e3a1396f..6f9f4677bb4 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -626,12 +626,9 @@ def test_console_member_invite_documents_bad_request_response(): } -def test_console_billing_routes_document_error_responses(monkeypatch: pytest.MonkeyPatch): - from configs import dify_config +def test_console_billing_routes_document_error_responses(): from controllers.console import bp as console_bp - monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) - app = Flask(__name__) app.config["TESTING"] = True app.config["RESTX_INCLUDE_ALL_MODELS"] = True @@ -684,12 +681,9 @@ def test_console_billing_routes_document_error_responses(monkeypatch: pytest.Mon assert compliance_response["required"] == ["url"] -def test_console_model_provider_checkout_route_is_deprecated(monkeypatch: pytest.MonkeyPatch): - from configs import dify_config +def test_console_model_provider_checkout_route_is_deprecated(): from controllers.console import bp as console_bp - monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) - app = Flask(__name__) app.config["TESTING"] = True app.config["RESTX_INCLUDE_ALL_MODELS"] = True diff --git a/api/tests/unit_tests/controllers/web/test_audio.py b/api/tests/unit_tests/controllers/web/test_audio.py index abcc27a9d47..10dc7a3429f 100644 --- a/api/tests/unit_tests/controllers/web/test_audio.py +++ b/api/tests/unit_tests/controllers/web/test_audio.py @@ -146,24 +146,21 @@ class TestAudioApi: # --------------------------------------------------------------------------- class TestTextApi: @patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes") - @patch("controllers.web.audio.web_ns") - def test_happy_path(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None: - mock_ns.payload = {"text": "hello", "voice": "alloy"} - - with app.test_request_context("/text-to-audio", method="POST"): + def test_happy_path(self, mock_tts: MagicMock, app: Flask) -> None: + with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello", "voice": "alloy"}): result = TextApi().post(_app_model(), _end_user()) assert result == "audio-bytes" mock_tts.assert_called_once() @patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes") - @patch("controllers.web.audio.web_ns") - def test_happy_path_with_message_ref(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None: + def test_happy_path_with_message_ref(self, mock_tts: MagicMock, app: Flask) -> None: message_id = "550e8400-e29b-41d4-a716-446655440000" - mock_ns.payload = {"text": "hello", "message_id": message_id} app_model = _app_model() - with app.test_request_context("/text-to-audio", method="POST"): + with app.test_request_context( + "/text-to-audio", method="POST", json={"text": "hello", "message_id": message_id} + ): result = TextApi().post(app_model, _end_user()) assert result == "audio-bytes" @@ -177,10 +174,7 @@ class TestTextApi: "controllers.web.audio.AudioService.transcript_tts", side_effect=InvokeError(description="invoke failed"), ) - @patch("controllers.web.audio.web_ns") - def test_invoke_error_mapped(self, mock_ns: MagicMock, mock_tts: MagicMock, app: Flask) -> None: - mock_ns.payload = {"text": "hello"} - - with app.test_request_context("/text-to-audio", method="POST"): + def test_invoke_error_mapped(self, mock_tts: MagicMock, app: Flask) -> None: + with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}): with pytest.raises(CompletionRequestError): TextApi().post(_app_model(), _end_user()) diff --git a/api/tests/unit_tests/controllers/web/test_remote_files.py b/api/tests/unit_tests/controllers/web/test_remote_files.py index ae912489e14..68cb0e6b6bb 100644 --- a/api/tests/unit_tests/controllers/web/test_remote_files.py +++ b/api/tests/unit_tests/controllers/web/test_remote_files.py @@ -134,12 +134,10 @@ class TestRemoteFileUploadApi: @patch("controllers.web.remote_files.FileService") @patch("controllers.web.remote_files.helpers.guess_file_info_from_response") @patch("controllers.web.remote_files.remote_fetcher") - @patch("controllers.web.remote_files.web_ns") @patch("controllers.web.remote_files.db") def test_upload_success( self, mock_db: MagicMock, - mock_ns: MagicMock, mock_proxy: MagicMock, mock_guess: MagicMock, mock_file_svc_cls: MagicMock, @@ -148,7 +146,6 @@ class TestRemoteFileUploadApi: sqlite_engine: Engine, ) -> None: mock_db.engine = sqlite_engine - mock_ns.payload = {"url": "https://example.com/file.pdf"} head_resp = MagicMock() head_resp.status_code = 200 head_resp.content = b"pdf-content" @@ -164,7 +161,9 @@ class TestRemoteFileUploadApi: mock_file_svc_cls.return_value.upload_file.return_value = _upload_file() - with app.test_request_context("/remote-files/upload", method="POST"): + with app.test_request_context( + "/remote-files/upload", method="POST", json={"url": "https://example.com/file.pdf"} + ): result, status = RemoteFileUploadApi().post(_app_model(), _end_user()) assert status == 201 @@ -173,16 +172,13 @@ class TestRemoteFileUploadApi: @patch("controllers.web.remote_files.FileService.is_file_size_within_limit", return_value=False) @patch("controllers.web.remote_files.helpers.guess_file_info_from_response") @patch("controllers.web.remote_files.remote_fetcher") - @patch("controllers.web.remote_files.web_ns") def test_file_too_large( self, - mock_ns: MagicMock, mock_proxy: MagicMock, mock_guess: MagicMock, mock_size_check: MagicMock, app: Flask, ) -> None: - mock_ns.payload = {"url": "https://example.com/big.zip"} head_resp = MagicMock() head_resp.status_code = 200 mock_proxy.make_request.return_value = head_resp @@ -190,18 +186,18 @@ class TestRemoteFileUploadApi: filename="big.zip", extension="zip", mimetype="application/zip", size=999999999 ) - with app.test_request_context("/remote-files/upload", method="POST"): + with app.test_request_context( + "/remote-files/upload", method="POST", json={"url": "https://example.com/big.zip"} + ): with pytest.raises(FileTooLargeError): RemoteFileUploadApi().post(_app_model(), _end_user()) @patch("controllers.web.remote_files.remote_fetcher") - @patch("controllers.web.remote_files.web_ns") - def test_fetch_failure_raises(self, mock_ns: MagicMock, mock_proxy: MagicMock, app: Flask) -> None: + def test_fetch_failure_raises(self, mock_proxy: MagicMock, app: Flask) -> None: import httpx - mock_ns.payload = {"url": "https://example.com/bad"} mock_proxy.make_request.side_effect = httpx.RequestError("connection failed") - with app.test_request_context("/remote-files/upload", method="POST"): + with app.test_request_context("/remote-files/upload", method="POST", json={"url": "https://example.com/bad"}): with pytest.raises(RemoteFileUploadError): RemoteFileUploadApi().post(_app_model(), _end_user()) diff --git a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py index 5532220f52f..f43bf1fbf0a 100644 --- a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py @@ -18,6 +18,7 @@ from enums import DeploymentEdition from models.account import Account from models.engine import db from services.entities.feature_entities import SystemFeatureModel +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -39,7 +40,7 @@ def _patch_wraps(): ) with ( patch("controllers.console.wraps.db") as mock_db, - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): yield diff --git a/api/tests/unit_tests/controllers/web/test_workflow.py b/api/tests/unit_tests/controllers/web/test_workflow.py index 2013b1e8db5..711976ed441 100644 --- a/api/tests/unit_tests/controllers/web/test_workflow.py +++ b/api/tests/unit_tests/controllers/web/test_workflow.py @@ -11,11 +11,15 @@ from controllers.web.error import ( NotWorkflowAppError, ProviderNotInitializeError, ProviderQuotaExceededError, + TriggerWorkflowServiceModeUnavailableError, ) from controllers.web.workflow import WorkflowRunApi, WorkflowTaskStopApi from core.errors.error import ProviderTokenNotInitError, QuotaExceededError from models.enums import EndUserType from models.model import App, AppMode, EndUser +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError, +) def _workflow_app() -> App: @@ -68,6 +72,26 @@ class TestWorkflowRunApi: with pytest.raises(ProviderNotInitializeError): WorkflowRunApi().post(_workflow_app(), _end_user()) + @patch( + "controllers.web.workflow.AppGenerateService.generate", + side_effect=TriggerWorkflowServiceModeUnavailableServiceError(), + ) + @patch("controllers.web.workflow.web_ns") + def test_trigger_workflow_returns_stable_unavailable_error( + self, + mock_ns: MagicMock, + mock_gen: MagicMock, + app: Flask, + ) -> None: + mock_ns.payload = {"inputs": {}} + + with app.test_request_context("/workflows/run", method="POST"): + with pytest.raises(TriggerWorkflowServiceModeUnavailableError) as exc_info: + WorkflowRunApi().post(_workflow_app(), _end_user()) + + assert exc_info.value.code == 403 + assert exc_info.value.error_code == "trigger_workflow_service_mode_unavailable" + @patch( "controllers.web.workflow.AppGenerateService.generate", side_effect=QuotaExceededError(), diff --git a/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py b/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py index 6dbf301f656..56a19e54149 100644 --- a/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py +++ b/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py @@ -1,26 +1,24 @@ -from unittest.mock import MagicMock - import pytest # Module under test from core.app.app_config.common import parameters_mapping +from tests.unit_tests.config_override import apply_config_overrides class TestGetParametersFromFeatureDict: """Test suite for get_parameters_from_feature_dict""" @pytest.fixture - def mock_config(self, monkeypatch: pytest.MonkeyPatch): - """Mock dify_config values""" - mock = MagicMock() - mock.UPLOAD_IMAGE_FILE_SIZE_LIMIT = 1 - mock.UPLOAD_VIDEO_FILE_SIZE_LIMIT = 2 - mock.UPLOAD_AUDIO_FILE_SIZE_LIMIT = 3 - mock.UPLOAD_FILE_SIZE_LIMIT = 4 - mock.WORKFLOW_FILE_UPLOAD_LIMIT = 5 - - monkeypatch.setattr(parameters_mapping, "dify_config", mock) - return mock + def mock_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Override file limits on the shared typed config.""" + apply_config_overrides( + monkeypatch, + UPLOAD_IMAGE_FILE_SIZE_LIMIT=1, + UPLOAD_VIDEO_FILE_SIZE_LIMIT=2, + UPLOAD_AUDIO_FILE_SIZE_LIMIT=3, + UPLOAD_FILE_SIZE_LIMIT=4, + WORKFLOW_FILE_UPLOAD_LIMIT=5, + ) @pytest.fixture def mock_default_file_limits(self, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index b5396e3acd5..b99e0d23394 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -1,12 +1,15 @@ from __future__ import annotations +import json import logging from contextlib import contextmanager +from decimal import Decimal from types import SimpleNamespace from unittest.mock import MagicMock import pytest from pydantic import BaseModel, ValidationError +from sqlalchemy import Engine, event from sqlalchemy.orm import Session from constants import UUID_NIL @@ -21,8 +24,88 @@ from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.ops.ops_trace_manager import TraceQueueManager from libs.datetime_utils import naive_utc_now -from models.enums import MessageStatus -from models.model import AppMode +from models.account import Account +from models.enums import ConversationFromSource, EndUserType, MessageStatus +from models.model import App, AppMode, Conversation, EndUser, Message +from models.workflow import Workflow, WorkflowType +from tests.unit_tests.config_override import apply_config_overrides + + +def _make_app(*, app_id: str = "app", tenant_id: str = "tenant") -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Advanced Chat App", + mode=AppMode.ADVANCED_CHAT, + enable_site=False, + enable_api=False, + ) + + +def _make_workflow( + *, + workflow_id: str = "workflow-id", + tenant_id: str = "tenant", + app_id: str = "app", + features: dict[str, object] | None = None, +) -> Workflow: + return Workflow( + id=workflow_id, + tenant_id=tenant_id, + app_id=app_id, + type=WorkflowType.CHAT, + version=Workflow.VERSION_DRAFT, + graph="{}", + features=json.dumps(features or {}), + created_by="user", + ) + + +def _make_account(*, account_id: str = "user-id") -> Account: + account = Account(name="Advanced Chat User", email=f"{account_id}@example.com") + account.id = account_id + return account + + +def _make_end_user(*, end_user_id: str = "end-user-id", session_id: str = "session-id") -> EndUser: + return EndUser( + id=end_user_id, + tenant_id="tenant", + app_id="app", + type=EndUserType.BROWSER, + session_id=session_id, + ) + + +def _make_conversation(*, conversation_id: str = "conversation-id", app_id: str = "app") -> Conversation: + return Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.ADVANCED_CHAT, + name="Advanced Chat Conversation", + inputs={}, + from_source=ConversationFromSource.API, + ) + + +def _make_message( + *, message_id: str = "message-id", conversation_id: str = "conversation-id", app_id: str = "app" +) -> Message: + return Message( + id=message_id, + app_id=app_id, + conversation_id=conversation_id, + inputs={}, + query="hello", + message={}, + answer="", + status=MessageStatus.NORMAL, + message_unit_price=Decimal(0), + answer_unit_price=Decimal(0), + currency="USD", + from_source=ConversationFromSource.API, + created_at=naive_utc_now(), + ) class TestAdvancedChatAppGeneratorValidation: @@ -31,9 +114,9 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="query is required"): generator.generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), - user=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), + user=_make_account(), args={"inputs": {}}, invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", @@ -46,9 +129,9 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="query must be a string"): generator.generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), - user=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), + user=_make_account(), args={"inputs": {}, "query": 123}, invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", @@ -61,10 +144,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="node_id is required"): generator.single_iteration_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="", - user=SimpleNamespace(), + user=_make_account(), args={"inputs": {}}, streaming=False, session=unbound_session, @@ -72,10 +155,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="inputs is required"): generator.single_iteration_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="node", - user=SimpleNamespace(), + user=_make_account(), args={}, streaming=False, session=unbound_session, @@ -86,10 +169,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="node_id is required"): generator.single_loop_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="", - user=SimpleNamespace(), + user=_make_account(), args=SimpleNamespace(inputs={}), streaming=False, session=unbound_session, @@ -97,10 +180,10 @@ class TestAdvancedChatAppGeneratorValidation: with pytest.raises(ValueError, match="inputs is required"): generator.single_loop_generate( - app_model=SimpleNamespace(), - workflow=SimpleNamespace(), + app_model=_make_app(), + workflow=_make_workflow(), node_id="node", - user=SimpleNamespace(), + user=_make_account(), args=SimpleNamespace(inputs=None), streaming=False, session=unbound_session, @@ -119,11 +202,13 @@ class TestAdvancedChatAppGeneratorInternals: workflow_id="workflow-id", ) - def test_generate_loads_conversation_and_files(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): + def test_generate_loads_conversation_and_files( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() - conversation = SimpleNamespace(id="conversation-id") + conversation = _make_conversation() built_files: list[object] = [] build_files_called = {"called": False} captured: dict[str, object] = {} @@ -156,10 +241,7 @@ class TestAdvancedChatAppGeneratorInternals: ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=lambda: SimpleNamespace(close=lambda: None)), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace() + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda **kwargs: kwargs["user_inputs"]) @@ -186,8 +268,8 @@ class TestAdvancedChatAppGeneratorInternals: user.id = "user-id" result = generator.generate( - app_model=SimpleNamespace(id="app", tenant_id="tenant"), - workflow=SimpleNamespace(features_dict={}), + app_model=_make_app(), + workflow=_make_workflow(), user=user, args={ "query": "hello", @@ -237,11 +319,11 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(id="app-id"), - workflow=SimpleNamespace(), - user=SimpleNamespace(id="end-user-id", session_id="session-id"), - conversation=SimpleNamespace(id="conversation-id"), - message=SimpleNamespace(id="message-id"), + app_model=_make_app(app_id="app-id"), + workflow=_make_workflow(), + user=_make_end_user(), + conversation=_make_conversation(), + message=_make_message(), session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), @@ -256,7 +338,7 @@ class TestAdvancedChatAppGeneratorInternals: assert captured_graph_runtime_state is not None def test_single_iteration_generate_builds_debug_task( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine ): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() @@ -264,7 +346,7 @@ class TestAdvancedChatAppGeneratorInternals: prefill_calls: list[object] = [] draft_sessions: list[object] = [] var_loader = SimpleNamespace(loader="draft") - workflow = SimpleNamespace(id="workflow-id") + workflow = _make_workflow() session = unbound_session monkeypatch.setattr( @@ -280,12 +362,9 @@ class TestAdvancedChatAppGeneratorInternals: lambda **kwargs: SimpleNamespace(repo="node"), ) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace() - ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=lambda: SimpleNamespace()), + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) class _DraftVarService: @@ -304,10 +383,10 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.single_iteration_generate( - app_model=SimpleNamespace(id="app", tenant_id="tenant"), + app_model=_make_app(), workflow=workflow, node_id="node-1", - user=SimpleNamespace(id="user-id"), + user=_make_account(), args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"}, streaming=False, session=session, @@ -321,14 +400,16 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["application_generate_entity"].single_iteration_run.node_id == "node-1" assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" - def test_single_loop_generate_builds_debug_task(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): + def test_single_loop_generate_builds_debug_task( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() captured: dict[str, object] = {} prefill_calls: list[object] = [] draft_sessions: list[object] = [] var_loader = SimpleNamespace(loader="draft") - workflow = SimpleNamespace(id="workflow-id") + workflow = _make_workflow() session = unbound_session monkeypatch.setattr( @@ -344,12 +425,9 @@ class TestAdvancedChatAppGeneratorInternals: lambda **kwargs: SimpleNamespace(repo="node"), ) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", lambda **kwargs: SimpleNamespace() - ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=lambda: SimpleNamespace()), + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) class _DraftVarService: @@ -368,10 +446,10 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.single_loop_generate( - app_model=SimpleNamespace(id="app", tenant_id="tenant"), + app_model=_make_app(), workflow=workflow, node_id="node-2", - user=SimpleNamespace(id="user-id"), + user=_make_account(), args=SimpleNamespace(inputs={"foo": "bar"}, trace_session_id="session-1"), streaming=False, session=session, @@ -385,7 +463,9 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["application_generate_entity"].single_loop_run.node_id == "node-2" assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" - def test_generate_internal_flow_initial_conversation_with_pause_layer(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_internal_flow_initial_conversation_with_pause_layer( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 0 app_config = self._build_app_config() @@ -404,16 +484,19 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - workflow = SimpleNamespace(id="wf-1", tenant_id="tenant", features={"feature": True}, features_dict={}) - conversation = SimpleNamespace(id="conv-1", mode=AppMode.ADVANCED_CHAT, override_model_configs=None) - message = SimpleNamespace( - id="msg-1", - query="hello", - created_at=naive_utc_now(), - status=MessageStatus.NORMAL, - answer="", - ) - db_session = SimpleNamespace(commit=MagicMock(), refresh=MagicMock(), close=MagicMock()) + app = _make_app() + workflow = _make_workflow(workflow_id="wf-1", features={"feature": True}) + conversation = _make_conversation(conversation_id="conv-1") + message = _make_message(message_id="msg-1", conversation_id=conversation.id) + sqlite_session.add_all([app, workflow, conversation, message]) + sqlite_session.commit() + commit_count = 0 + + def _record_commit(session: Session) -> None: + nonlocal commit_count + commit_count += 1 + + event.listen(sqlite_session, "after_commit", _record_commit) captured: dict[str, object] = {} thread_data: dict[str, object] = {} init_records = MagicMock(return_value=(conversation, message)) @@ -454,7 +537,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session) + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( @@ -471,10 +555,10 @@ class TestAdvancedChatAppGeneratorInternals: response = generator._generate( workflow=workflow, - user=SimpleNamespace(id="user"), + user=_make_account(account_id="user"), invoke_from=InvokeFrom.WEB_APP, application_generate_entity=application_generate_entity, - session=db_session, + session=sqlite_session, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), conversation=None, @@ -489,17 +573,18 @@ class TestAdvancedChatAppGeneratorInternals: assert thread_data["join_timeout"] == 300 assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"] assert generator._dialogue_count == 3 - assert init_records.call_args.kwargs["session"] is db_session - get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session) - db_session.commit.assert_called_once() - db_session.refresh.assert_called_once_with(conversation) - db_session.close.assert_called_once() + assert init_records.call_args.kwargs["session"] is sqlite_session + get_thread_messages_length.assert_called_once_with(conversation.id, session=sqlite_session) + assert commit_count == 1 + assert json.loads(conversation.override_model_configs) == {"feature": True} assert captured["draft_var_saver_factory"] == "draft-factory" assert isinstance(captured["workflow"], WorkflowSnapshot) assert isinstance(captured["conversation"], ConversationSnapshot) assert isinstance(captured["message"], MessageSnapshot) - def test_generate_internal_flow_with_existing_records_skips_init(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_internal_flow_with_existing_records_skips_init( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 0 app_config = self._build_app_config() @@ -518,16 +603,19 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - workflow = SimpleNamespace(id="wf-2", tenant_id="tenant", features={}, features_dict={}) - conversation = SimpleNamespace(id="conv-2", mode=AppMode.ADVANCED_CHAT, override_model_configs=None) - message = SimpleNamespace( - id="msg-2", - query="hello", - created_at=naive_utc_now(), - status=MessageStatus.NORMAL, - answer="", - ) - db_session = SimpleNamespace(close=MagicMock(), commit=MagicMock(), refresh=MagicMock()) + app = _make_app() + workflow = _make_workflow(workflow_id="wf-2") + conversation = _make_conversation(conversation_id="conv-2") + message = _make_message(message_id="msg-2", conversation_id=conversation.id) + sqlite_session.add_all([app, workflow, conversation, message]) + sqlite_session.commit() + commit_count = 0 + + def _record_commit(session: Session) -> None: + nonlocal commit_count + commit_count += 1 + + event.listen(sqlite_session, "after_commit", _record_commit) init_records = MagicMock() get_thread_messages_length = MagicMock(return_value=0) thread_data: dict[str, object] = {} @@ -563,7 +651,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session) + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( @@ -578,10 +667,10 @@ class TestAdvancedChatAppGeneratorInternals: response = generator._generate( workflow=workflow, - user=SimpleNamespace(id="user"), + user=_make_account(account_id="user"), invoke_from=InvokeFrom.WEB_APP, application_generate_entity=application_generate_entity, - session=db_session, + session=sqlite_session, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), conversation=conversation, @@ -591,15 +680,15 @@ class TestAdvancedChatAppGeneratorInternals: assert response == {"raw": True} init_records.assert_not_called() - get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session) + get_thread_messages_length.assert_called_once_with(conversation.id, session=sqlite_session) assert thread_data["started"] is True assert thread_data["joined"] is True assert thread_data["join_timeout"] == 300 - db_session.commit.assert_not_called() - db_session.refresh.assert_not_called() - db_session.close.assert_called_once() + assert commit_count == 0 - def test_generate_worker_raises_when_workflow_not_found(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_raises_when_workflow_not_found( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -618,8 +707,8 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) @contextmanager def _fake_context(*args, **kwargs): @@ -627,20 +716,9 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock(return_value=None) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) with pytest.raises(ValueError, match="Workflow not found"): @@ -658,7 +736,9 @@ class TestAdvancedChatAppGeneratorInternals: graph_runtime_state=None, ) - def test_generate_worker_raises_when_app_not_found_for_internal_call(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_raises_when_app_not_found_for_internal_call( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -677,8 +757,10 @@ class TestAdvancedChatAppGeneratorInternals: workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + sqlite_session.add(_make_workflow()) + sqlite_session.commit() @contextmanager def _fake_context(*args, **kwargs): @@ -686,25 +768,9 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - None, - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) with pytest.raises(ValueError, match="App not found"): @@ -722,7 +788,9 @@ class TestAdvancedChatAppGeneratorInternals: graph_runtime_state=None, ) - def test_generate_worker_handles_stopped_error(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_stopped_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -742,8 +810,8 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) @contextmanager def _fake_context(*args, **kwargs): @@ -751,22 +819,8 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app") - - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - workflow, - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False + sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.commit() class _Runner: def __init__(self, **kwargs): @@ -775,13 +829,12 @@ class TestAdvancedChatAppGeneratorInternals: def run(self): raise GenerateTaskStoppedError() - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) restore_workflow_run_graph = MagicMock() monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -799,10 +852,12 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager.publish_error.assert_not_called() - assert restore_workflow_run_graph.call_args.kwargs["workflow"] is workflow + assert restore_workflow_run_graph.call_args.kwargs["workflow"].id == "workflow-id" assert restore_workflow_run_graph.call_args.kwargs["workflow_run_id"] == "run-id" - def test_generate_worker_handles_validation_error(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_validation_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -832,8 +887,10 @@ class TestAdvancedChatAppGeneratorInternals: raise AssertionError("validation error should be created") queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.commit() @contextmanager def _fake_context(*args, **kwargs): @@ -841,21 +898,6 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - class _Runner: def __init__(self, **kwargs): _ = kwargs @@ -863,11 +905,10 @@ class TestAdvancedChatAppGeneratorInternals: def run(self): raise validation_error - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -886,8 +927,12 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager.publish_error.assert_called_once() - def test_generate_worker_handles_value_and_unknown_errors(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_value_and_unknown_errors( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): app_config = self._build_app_config() + sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.commit() @contextmanager def _fake_context(*args, **kwargs): @@ -921,34 +966,18 @@ class TestAdvancedChatAppGeneratorInternals: ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv")) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) - - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _make_runner(raised_error), ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.dify_config", SimpleNamespace(DEBUG=True)) + apply_config_overrides(monkeypatch, DEBUG=True) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -1018,7 +1047,7 @@ class TestAdvancedChatAppGeneratorInternals: status=MessageStatus.NORMAL, answer="", ), - user=SimpleNamespace(), + user=_make_account(), draft_var_saver_factory=lambda **kwargs: None, stream=False, ) @@ -1066,14 +1095,16 @@ class TestAdvancedChatAppGeneratorInternals: status=MessageStatus.NORMAL, answer="", ), - user=SimpleNamespace(), + user=_make_account(), draft_var_saver_factory=lambda **kwargs: None, stream=False, ) assert "Failed to process generate task pipeline, conversation_id: conv" in caplog.messages - def test_generate_worker_handles_invoke_auth_error(self, monkeypatch: pytest.MonkeyPatch): + def test_generate_worker_handles_invoke_auth_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + ): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 @@ -1101,8 +1132,10 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=SimpleNamespace(id="conv", mode=AppMode.ADVANCED_CHAT)) - generator._get_message = MagicMock(return_value=SimpleNamespace(id="msg")) + generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) + generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) + sqlite_session.add_all([_make_app(), _make_workflow(), _make_end_user()]) + sqlite_session.commit() class _Runner: def __init__(self, **kwargs) -> None: @@ -1121,26 +1154,9 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - class _Session: - def __init__(self, *args, **kwargs): - self.scalar = MagicMock( - side_effect=[ - SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"), - SimpleNamespace(id="end-user-id", session_id="session-id"), - SimpleNamespace(id="app"), - ] - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), + SimpleNamespace(engine=sqlite_engine, session=sqlite_session), ) generator._generate_worker( @@ -1159,88 +1175,8 @@ class TestAdvancedChatAppGeneratorInternals: assert queue_manager.publish_error.called - def test_generate_debugger_enables_retrieve_source(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): - generator = AdvancedChatAppGenerator() - - app_config = WorkflowUIBasedAppConfig( - tenant_id="tenant", - app_id="app", - app_mode=AppMode.ADVANCED_CHAT, - additional_features=AppAdditionalFeatures(), - variables=[], - workflow_id="workflow-id", - ) - - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", - lambda app_model, workflow: app_config, - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", - lambda features_dict, is_vision=False: None, - ) - DummyTraceQueueManager = type( - "_DummyTraceQueueManager", - (TraceQueueManager,), - { - "__init__": lambda self, app_id=None, user_id=None: ( - setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) - ) - }, - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.TraceQueueManager", - DummyTraceQueueManager, - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", - lambda **kwargs: SimpleNamespace(), - ) - - captured = {} - - def _fake_generate(**kwargs): - captured.update(kwargs) - return {"ok": True} - - monkeypatch.setattr(generator, "_generate", _fake_generate) - - app_model = SimpleNamespace(id="app", tenant_id="tenant") - workflow = SimpleNamespace(features_dict={}) - from models import Account - - user = Account(name="Tester", email="tester@example.com") - user.id = "user" - - result = generator.generate( - app_model=app_model, - workflow=workflow, - user=user, - args={"query": "hello\x00", "inputs": {}}, - invoke_from=InvokeFrom.DEBUGGER, - workflow_run_id="run-id", - streaming=False, - session=unbound_session, - ) - - assert result == {"ok": True} - assert app_config.additional_features.show_retrieve_source is True - assert captured["application_generate_entity"].query == "hello" - - def test_generate_service_api_sets_parent_message_id( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session + def test_generate_debugger_enables_retrieve_source( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine ): generator = AdvancedChatAppGenerator() @@ -1284,11 +1220,7 @@ class TestAdvancedChatAppGeneratorInternals: ) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.sessionmaker", - lambda **kwargs: SimpleNamespace(), + SimpleNamespace(engine=sqlite_engine, session=unbound_session), ) captured = {} @@ -1299,12 +1231,84 @@ class TestAdvancedChatAppGeneratorInternals: monkeypatch.setattr(generator, "_generate", _fake_generate) - app_model = SimpleNamespace(id="app", tenant_id="tenant") - workflow = SimpleNamespace(features_dict={}) - from models.model import EndUser + app_model = _make_app() + workflow = _make_workflow() + user = _make_account(account_id="user") - user = EndUser(tenant_id="tenant", type="session", name="tester", session_id="session") - user.id = "end-user" + result = generator.generate( + app_model=app_model, + workflow=workflow, + user=user, + args={"query": "hello\x00", "inputs": {}}, + invoke_from=InvokeFrom.DEBUGGER, + workflow_run_id="run-id", + streaming=False, + session=unbound_session, + ) + + assert result == {"ok": True} + assert app_config.additional_features.show_retrieve_source is True + assert captured["application_generate_entity"].query == "hello" + + def test_generate_service_api_sets_parent_message_id( + self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + ): + generator = AdvancedChatAppGenerator() + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.ADVANCED_CHAT, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + lambda app_model, workflow: app_config, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + lambda features_dict, is_vision=False: None, + ) + DummyTraceQueueManager = type( + "_DummyTraceQueueManager", + (TraceQueueManager,), + { + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) + }, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + DummyTraceQueueManager, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: SimpleNamespace(), + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: SimpleNamespace(), + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.db", + SimpleNamespace(engine=sqlite_engine, session=unbound_session), + ) + + captured = {} + + def _fake_generate(**kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(generator, "_generate", _fake_generate) + + app_model = _make_app() + workflow = _make_workflow() + user = _make_end_user(end_user_id="end-user", session_id="session") generator.generate( app_model=app_model, @@ -1375,11 +1379,11 @@ class TestAdvancedChatAppGeneratorResume: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(id="app-id"), - workflow=SimpleNamespace(), - user=SimpleNamespace(id="end-user-id", session_id="session-id"), - conversation=SimpleNamespace(id="conversation-id"), - message=SimpleNamespace(id="message-id"), + app_model=_make_app(app_id="app-id"), + workflow=_make_workflow(), + user=_make_end_user(), + conversation=_make_conversation(), + message=_make_message(), session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), @@ -1423,11 +1427,11 @@ class TestAdvancedChatAppGeneratorResume: monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(id="app-id"), - workflow=SimpleNamespace(), - user=SimpleNamespace(id="end-user-id", session_id="session-id"), - conversation=SimpleNamespace(id="conversation-id"), - message=SimpleNamespace(id="message-id"), + app_model=_make_app(app_id="app-id"), + workflow=_make_workflow(), + user=_make_end_user(), + conversation=_make_conversation(), + message=_make_message(), session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index ca0e989d9d1..f4a0c4e90f3 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -21,6 +21,7 @@ from core.app.apps.agent_app.app_generator import ( AgentAppGenerator, AgentAppGeneratorError, ) +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import QueueAnnotationReplyEvent @@ -427,6 +428,23 @@ class TestGenerateWorker: self._call(generator, mocker, queue_manager) assert queue_manager.publish_error.called + def test_session_configuration_change_is_published_without_unknown_error_log( + self, + generator: AgentAppGenerator, + mocker: MockerFixture, + ) -> None: + error = AgentSessionSnapshotIncompatibleError() + self._wire(generator, mocker, run_side_effect=error) + queue_manager = mocker.MagicMock() + info_log = mocker.patch(f"{MODULE}.logger.info") + exception_log = mocker.patch(f"{MODULE}.logger.exception") + + self._call(generator, mocker, queue_manager) + + queue_manager.publish_error.assert_called_once_with(error, module.PublishFrom.APPLICATION_MANAGER) + info_log.assert_called_once() + exception_log.assert_not_called() + class TestResumeAfterFormSubmission: """ENG-638: a resume turn re-sends the paused turn's original query so the diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index a9c27c61d47..1099a252dea 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -12,7 +12,8 @@ from typing import Any, override from unittest.mock import MagicMock import pytest -from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import LifecycleState from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import ( AgentRunUsage, @@ -52,7 +53,8 @@ from clients.agent_backend import ( ) from core.app.apps.agent_app import app_runner as app_runner_module from core.app.apps.agent_app.app_runner import AgentAppRunner -from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError +from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom @@ -628,6 +630,34 @@ def _dify_ctx() -> DifyRunContext: ) +def _compatible_session_snapshot() -> CompositorSessionSnapshot: + request = ( + AgentAppRuntimeRequestBuilder( + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + .build( + AgentAppRuntimeBuildContext( + dify_context=_dify_ctx(), + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + agent_soul=_soul(), + conversation_id="conv-1", + user_query="hello", + idempotency_key="msg-1", + binding_id="binding-1", + backend_binding_ref="backend-binding-1", + ) + ) + .request + ) + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name=layer.name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}) + for layer in request.composition.layers + ] + ) + + def _runner( client: FakeAgentBackendRunClient, store: _FakeSessionStore, @@ -1314,7 +1344,7 @@ def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_di def test_prior_session_snapshot_is_threaded_into_request() -> None: - prior = CompositorSessionSnapshot(layers=[]) + prior = _compatible_session_snapshot() client = FakeAgentBackendRunClient() store = _FakeSessionStore(loaded=prior) qm = _FakeQueueManager() @@ -1325,8 +1355,23 @@ def test_prior_session_snapshot_is_threaded_into_request() -> None: assert client.request.session_snapshot is prior +def test_incompatible_session_snapshot_is_rejected_before_backend_invocation() -> None: + compatible = _compatible_session_snapshot() + stale = CompositorSessionSnapshot( + layers=[layer for layer in compatible.layers if layer.name != "agent_soul_prompt"] + ) + client = FakeAgentBackendRunClient() + store = _FakeSessionStore(loaded=stale) + + with pytest.raises(AgentSessionSnapshotIncompatibleError, match="Start a new conversation"): + _run(_runner(client, store), _FakeQueueManager()) + + assert client.request is None + assert store.saved == [] + + def test_debug_session_scope_can_reuse_conversation_across_config_snapshots() -> None: - prior = CompositorSessionSnapshot(layers=[]) + prior = _compatible_session_snapshot() client = FakeAgentBackendRunClient() store = _FakeSessionStore(loaded=prior) qm = _FakeQueueManager() @@ -1599,7 +1644,7 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation() -> None: def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch: pytest.MonkeyPatch) -> None: # ENG-638: a turn that runs while a pending form is answered threads the # human's reply into the request as deferred_tool_results. - snapshot = CompositorSessionSnapshot(layers=[]) + snapshot = _compatible_session_snapshot() stored = StoredAgentAppSession( scope=AgentAppSessionScope( tenant_id="tenant-1", diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 152ada73f90..db33a1ad03b 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -6,6 +6,8 @@ from __future__ import annotations from types import SimpleNamespace import pytest +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import LifecycleState from dify_agent.layers.config import DifyConfigSkillConfig from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig @@ -20,6 +22,7 @@ from clients.agent_backend import ( AgentBackendRunRequestBuilder, ) from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder, @@ -27,6 +30,7 @@ from core.app.apps.agent_app.runtime_request_builder import ( ) from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from models.agent_config_entities import AgentSoulConfig +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -163,6 +167,7 @@ def _ctx( *, query: str = "hello", agent_config_version_kind: str = "snapshot", + session_snapshot: CompositorSessionSnapshot | None = None, ) -> AgentAppRuntimeBuildContext: dify_context = SimpleNamespace( tenant_id="tenant-1", @@ -182,6 +187,7 @@ def _ctx( binding_id="binding-1", backend_binding_ref="binding-ref-1", agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type] + session_snapshot=session_snapshot, ) @@ -198,6 +204,15 @@ def _soul_with_model() -> AgentSoulConfig: ) +def _snapshot_for_layer_names(layer_names: list[str]) -> CompositorSessionSnapshot: + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name=name, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}) + for name in layer_names + ] + ) + + class TestAgentAppRuntimeRequestBuilder: def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[tuple[object, str, str]]): builder = AgentAppRuntimeRequestBuilder( @@ -236,6 +251,57 @@ class TestAgentAppRuntimeRequestBuilder: assert "credentials" not in result.redacted_request["composition"]["layers"][-1]["config"] assert result.metadata["conversation_id"] == "conv-1" + @pytest.mark.parametrize( + ("previous_prompt", "current_prompt"), + [("", "You are Iris."), ("You are Iris.", "")], + ) + def test_build_rejects_session_snapshot_after_layer_topology_changes( + self, + previous_prompt: str, + current_prompt: str, + ) -> None: + builder = AgentAppRuntimeRequestBuilder( + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + previous_soul = _soul_with_model() + previous_soul.prompt.system_prompt = previous_prompt + previous_request = builder.build(_ctx(previous_soul, agent_config_version_kind="draft")).request + snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers]) + current_soul = _soul_with_model() + current_soul.prompt.system_prompt = current_prompt + + with pytest.raises(AgentSessionSnapshotIncompatibleError) as exc_info: + builder.build( + _ctx( + current_soul, + agent_config_version_kind="draft", + session_snapshot=snapshot, + ) + ) + + assert exc_info.value.error_code == "agent_session_configuration_changed" + assert exc_info.value.status_code == 409 + assert "Start a new conversation" in str(exc_info.value) + + def test_build_reuses_session_snapshot_when_config_changes_without_changing_layers(self) -> None: + builder = AgentAppRuntimeRequestBuilder( + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + previous_request = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="draft")).request + snapshot = _snapshot_for_layer_names([layer.name for layer in previous_request.composition.layers]) + current_soul = _soul_with_model() + current_soul.prompt.system_prompt = "You are Ada." + + result = builder.build( + _ctx( + current_soul, + agent_config_version_kind="draft", + session_snapshot=snapshot, + ) + ) + + assert result.request.session_snapshot is snapshot + def test_build_wraps_agent_soul_prompt_for_build_draft(self): builder = AgentAppRuntimeRequestBuilder( dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] @@ -403,7 +469,7 @@ class TestAgentAppRuntimeRequestBuilder: assert exc.value.error_code == "agent_model_not_configured" def test_build_maps_agent_soul_shell_settings_to_shell_layer(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) soul = AgentSoulConfig.model_validate( { "model": { @@ -482,7 +548,7 @@ class TestAgentAppConfigLayer: assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1 def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) builder = AgentAppRuntimeRequestBuilder( dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ) diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py index 29bfe2b4bb3..b64cb1c645a 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py @@ -11,6 +11,7 @@ from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom from graphon.model_runtime.errors.invoke import InvokeAuthorizationError +from tests.unit_tests.config_override import apply_config_overrides class DummyAccount: @@ -328,6 +329,7 @@ class TestAgentChatAppGeneratorWorker: self, generator, mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, sqlite_session_factory: sessionmaker[Session], ): @@ -343,7 +345,7 @@ class TestAgentChatAppGeneratorWorker: side_effect=sqlite_session_factory, ) - mocker.patch("core.app.apps.agent_chat.app_generator.dify_config", new=mocker.MagicMock(DEBUG=True)) + apply_config_overrides(monkeypatch, DEBUG=True) with caplog.at_level(logging.ERROR, logger="core.app.apps.agent_chat.app_generator"): generator._generate_worker( diff --git a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py index 6d90aa7e53b..c41c33487eb 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py @@ -4,12 +4,16 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.app.apps.base_app_runner import AppRunner from core.app.entities.app_invoke_entities import InvokeFrom from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent from models.enums import CreatorUserRole +from models.model import MessageFile +from models.tools import ToolFile class TestBaseAppRunnerMultimodal: @@ -38,18 +42,18 @@ class TestBaseAppRunnerMultimodal: return manager @pytest.fixture - def mock_tool_file(self): - """Create a mock tool file.""" - tool_file = MagicMock() - tool_file.id = str(uuid4()) - return tool_file - - @pytest.fixture - def mock_message_file(self): - """Create a mock message file.""" - message_file = MagicMock() - message_file.id = str(uuid4()) - return message_file + def tool_file(self, mock_user_id: str, mock_tenant_id: str) -> ToolFile: + """Create a real transient tool-file model returned by the external file manager.""" + return ToolFile( + user_id=mock_user_id, + tenant_id=mock_tenant_id, + conversation_id=None, + file_key="generated/image.png", + mimetype="image/png", + original_url="http://example.com/image.png", + name="image.png", + size=68, + ) def test_handle_multimodal_image_content_with_url( self, @@ -57,8 +61,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from URL.""" # Arrange @@ -72,48 +76,33 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - # Setup mock message file - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - # Act - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - message_file_id = runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - # Assert - mock_mgr.create_file_by_url.assert_called_once_with( - user_id=mock_user_id, - tenant_id=mock_tenant_id, - file_url=image_url, - conversation_id=None, - ) - - mock_msg_file_class.assert_called_once() - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["message_id"] == mock_message_id - assert call_kwargs["type"] == FileType.IMAGE - assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE - assert call_kwargs["belongs_to"] == "assistant" - assert call_kwargs["created_by"] == mock_user_id - - file_session.add.assert_called_once_with(mock_message_file) - file_session.flush.assert_called_once() - assert message_file_id == mock_message_file.id - mock_queue_manager.publish.assert_not_called() + mock_mgr.create_file_by_url.assert_called_once_with( + user_id=mock_user_id, + tenant_id=mock_tenant_id, + file_url=image_url, + conversation_id=None, + ) + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.message_id == mock_message_id + assert message_file.type == FileType.IMAGE + assert message_file.transfer_method == FileTransferMethod.TOOL_FILE + assert message_file.belongs_to == "assistant" + assert message_file.created_by == mock_user_id + assert message_file.upload_file_id == tool_file.id + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_base64( self, @@ -121,8 +110,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from base64 data.""" # Arrange @@ -141,41 +130,29 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_raw.return_value = mock_tool_file + mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - message_file_id = runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr.create_file_by_raw.assert_called_once() - call_kwargs = mock_mgr.create_file_by_raw.call_args[1] - assert call_kwargs["user_id"] == mock_user_id - assert call_kwargs["tenant_id"] == mock_tenant_id - assert call_kwargs["conversation_id"] is None - assert "file_binary" in call_kwargs - assert call_kwargs["mimetype"] == "image/png" - assert call_kwargs["filename"].startswith("generated_image") - assert call_kwargs["filename"].endswith(".png") - - mock_msg_file_class.assert_called_once() - file_session.add.assert_called_once() - file_session.flush.assert_called_once() - assert message_file_id == mock_message_file.id - mock_queue_manager.publish.assert_not_called() + mock_mgr.create_file_by_raw.assert_called_once() + call_kwargs = mock_mgr.create_file_by_raw.call_args[1] + assert call_kwargs["user_id"] == mock_user_id + assert call_kwargs["tenant_id"] == mock_tenant_id + assert call_kwargs["conversation_id"] is None + assert "file_binary" in call_kwargs + assert call_kwargs["mimetype"] == "image/png" + assert call_kwargs["filename"].startswith("generated_image") + assert call_kwargs["filename"].endswith(".png") + assert sqlite_session.get(MessageFile, message_file_id) is not None + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_base64_data_uri( self, @@ -183,8 +160,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from base64 data with URI prefix.""" # Arrange @@ -201,29 +178,22 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_raw.return_value = mock_tool_file + mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr.create_file_by_raw.assert_called_once() - call_kwargs = mock_mgr.create_file_by_raw.call_args[1] - assert "file_binary" in call_kwargs + mock_mgr.create_file_by_raw.assert_called_once() + call_kwargs = mock_mgr.create_file_by_raw.call_args[1] + assert "file_binary" in call_kwargs + assert sqlite_session.get(MessageFile, message_file_id) is not None def test_handle_multimodal_image_content_without_url_or_base64( self, @@ -231,6 +201,7 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, + sqlite_session: Session, ): """Test handling image content without URL or base64 data.""" # Arrange @@ -242,24 +213,19 @@ class TestBaseAppRunnerMultimodal: ) with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) + result = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr_class.assert_not_called() - mock_msg_file_class.assert_not_called() - mock_queue_manager.publish.assert_not_called() + assert result is None + assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0 + mock_mgr_class.assert_not_called() + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_error( self, @@ -267,6 +233,7 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, + sqlite_session: Session, ): """Test handling image content when an error occurs.""" # Arrange @@ -282,23 +249,18 @@ class TestBaseAppRunnerMultimodal: mock_mgr.create_file_by_url.side_effect = Exception("Network error") mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) + result = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_msg_file_class.assert_not_called() - mock_queue_manager.publish.assert_not_called() + assert result is None + assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0 + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_debugger_mode( self, @@ -306,8 +268,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test that debugger mode sets correct created_by_role.""" # Arrange @@ -321,28 +283,21 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.created_by_role == CreatorUserRole.ACCOUNT def test_handle_multimodal_image_content_service_api_mode( self, @@ -350,8 +305,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test that service API mode sets correct created_by_role.""" # Arrange @@ -365,25 +320,18 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.created_by_role == CreatorUserRole.END_USER diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py index eeabb51c27a..fb92e098702 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_queue_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from unittest.mock import Mock, patch from core.app.apps.base_app_queue_manager import PublishFrom @@ -79,12 +80,12 @@ class TestWorkflowAppQueueManager: graph_engine_manager.return_value.send_stop_command.assert_not_called() manager._execution_coordinator.mark_terminal() - def test_execution_timeout_aborts_graph_before_stop_event(self): + def test_execution_timeout_aborts_graph_before_stop_event(self, config_overrides: Callable[..., None]): + config_overrides(APP_MAX_EXECUTION_TIME=0) with ( patch("core.app.apps.base_app_queue_manager.redis_client") as queue_redis, patch("core.app.apps.execution_coordinator.redis_client") as execution_redis, patch("core.app.apps.execution_coordinator.GraphEngineManager") as graph_engine_manager, - patch("core.app.apps.execution_coordinator.dify_config.APP_MAX_EXECUTION_TIME", 0), ): queue_redis.get.return_value = None manager = WorkflowAppQueueManager( diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py b/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py index 18c2fdd97a2..26b3f521a3e 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py @@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType from sqlalchemy.orm import Session from clients.agent_backend.errors import AgentBackendRunFailedError +from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueErrorEvent @@ -168,6 +169,17 @@ class TestBasedGenerateTaskPipeline: "message": "run limit reached (agent_run_id=run-1)", } + def test_stream_converter_preserves_agent_session_configuration_error(self): + data = AppGenerateResponseConverter._error_to_stream_response(AgentSessionSnapshotIncompatibleError()) + + assert data == { + "code": "agent_session_configuration_changed", + "status": 409, + "message": ( + "The Agent configuration changed after this conversation started. Start a new conversation to continue." + ), + } + def test_handle_output_moderation_when_flagged(self, pipeline): handler = Mock() handler.moderation_completion.return_value = ("filtered", True) diff --git a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py index b283f8a211d..d13dea0c7b7 100644 --- a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py +++ b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py @@ -6,12 +6,13 @@ import pytest from core.app.workflow.layers.observability import ObservabilityLayer from graphon.enums import BuiltinNodeTypes +from tests.unit_tests.config_override import apply_config_overrides class TestObservabilityLayerExtras: def test_init_tracer_enabled_sets_tracer(self, monkeypatch: pytest.MonkeyPatch): tracer = object() - monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) + apply_config_overrides(monkeypatch, ENABLE_OTEL=True) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) monkeypatch.setattr("core.app.workflow.layers.observability.get_tracer", lambda _: tracer) @@ -23,7 +24,7 @@ class TestObservabilityLayerExtras: def test_init_tracer_disables_when_get_tracer_fails( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): - monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) + apply_config_overrides(monkeypatch, ENABLE_OTEL=True) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) def _raise(*_args, **_kwargs): @@ -38,7 +39,7 @@ class TestObservabilityLayerExtras: assert "Failed to get OpenTelemetry tracer" in caplog.text def test_init_tracer_disables_when_otel_disabled(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False) + apply_config_overrides(monkeypatch, ENABLE_OTEL=False) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) layer = ObservabilityLayer() diff --git a/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py index f9b3b1864e0..9d22e2f633f 100644 --- a/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture import core.callback_handler.agent_tool_callback_handler as module from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler +from tests.unit_tests.config_override import apply_config_overrides # ----------------------------- # Fixtures @@ -12,13 +13,13 @@ from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackH @pytest.fixture -def enable_debug(mocker: MockerFixture): - mocker.patch.object(module.dify_config, "DEBUG", True) +def enable_debug(monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, DEBUG=True) @pytest.fixture -def disable_debug(mocker: MockerFixture): - mocker.patch.object(module.dify_config, "DEBUG", False) +def disable_debug(monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, DEBUG=False) @pytest.fixture diff --git a/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py index 81ac48b2036..ea6451dc785 100644 --- a/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import MagicMock, call import pytest @@ -33,9 +34,9 @@ def mock_print_text(mocker: MockerFixture): @pytest.fixture -def enable_debug(mocker: MockerFixture): +def enable_debug(config_overrides: Callable[..., None]): """Force DEBUG on so the handler emits its verbose stdout traces.""" - mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", True) + config_overrides(DEBUG=True) class TestDifyWorkflowCallbackHandler: @@ -112,12 +113,15 @@ class TestDifyWorkflowCallbackHandler: mock_print_text.assert_not_called() def test_on_tool_execution_skips_print_when_debug_disabled( - self, handler: DifyWorkflowCallbackHandler, mock_print_text, mocker: MockerFixture + self, + handler: DifyWorkflowCallbackHandler, + mock_print_text, + config_overrides: Callable[..., None], ): """When DEBUG is off, outputs are still yielded but nothing is printed and model_dump_json() is never invoked.""" # Arrange - mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", False) + config_overrides(DEBUG=False) message = MagicMock() # Act diff --git a/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py b/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py index 5482b4db525..15f3d7e43ce 100644 --- a/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py +++ b/api/tests/unit_tests/core/datasource/__base/test_datasource_plugin.py @@ -1,6 +1,6 @@ -from unittest.mock import MagicMock, patch +from collections.abc import Callable +from unittest.mock import MagicMock -from configs import dify_config from core.datasource.__base.datasource_plugin import DatasourcePlugin from core.datasource.__base.datasource_runtime import DatasourceRuntime from core.datasource.entities.datasource_entities import DatasourceEntity, DatasourceProviderType @@ -69,7 +69,8 @@ class TestDatasourcePlugin: assert new_plugin.icon == icon mock_entity.model_copy.assert_called_once() - def test_get_icon_url(self): + def test_get_icon_url(self, config_overrides: Callable[..., None]): + config_overrides(CONSOLE_API_URL="https://api.dify.ai") # Arrange entity = MagicMock(spec=DatasourceEntity) runtime = MagicMock(spec=DatasourceRuntime) @@ -78,13 +79,9 @@ class TestDatasourcePlugin: plugin = ConcreteDatasourcePlugin(entity=entity, runtime=runtime, icon=icon) - # Mocking dify_config.CONSOLE_API_URL - with patch.object(dify_config, "CONSOLE_API_URL", "https://api.dify.ai"): - # Act - icon_url = plugin.get_icon_url(tenant_id) + icon_url = plugin.get_icon_url(tenant_id) - # Assert - expected_url = ( - f"https://api.dify.ai/console/api/workspaces/current/plugin/icon?tenant_id={tenant_id}&filename={icon}" - ) - assert icon_url == expected_url + expected_url = ( + f"https://api.dify.ai/console/api/workspaces/current/plugin/icon?tenant_id={tenant_id}&filename={icon}" + ) + assert icon_url == expected_url diff --git a/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py b/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py index 4ebf265193d..cc48adfb5b0 100644 --- a/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py +++ b/api/tests/unit_tests/core/entities/test_entities_mcp_provider.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch import pytest -from core.entities import mcp_provider as mcp_provider_module from core.entities.mcp_provider import ( DEFAULT_EXPIRES_IN, DEFAULT_TOKEN_TYPE, @@ -69,7 +68,9 @@ def test_from_db_model_maps_fields() -> None: def test_redirect_url_uses_console_api_url(monkeypatch: pytest.MonkeyPatch) -> None: # Arrange entity = _build_mcp_provider_entity() - monkeypatch.setattr(mcp_provider_module.dify_config, "CONSOLE_API_URL", "https://console.example.com") + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, CONSOLE_API_URL="https://console.example.com") # Act redirect_url = entity.redirect_url diff --git a/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py b/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py index 5ecc9fc5967..a967f28b4d1 100644 --- a/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py +++ b/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import httpx import pytest from pytest_mock import MockerFixture @@ -29,10 +31,8 @@ def test_request_success(mocker: MockerFixture): ) -def test_request_with_ssrf_proxy(mocker: MockerFixture): - # Mock dify_config - mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080") - mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", "https://proxy:8081") +def test_request_with_ssrf_proxy(mocker: MockerFixture, config_overrides: Callable[..., None]): + config_overrides(SSRF_PROXY_HTTP_URL="http://proxy:8080", SSRF_PROXY_HTTPS_URL="https://proxy:8081") # Mock httpx.Client mock_client = mocker.MagicMock() @@ -60,10 +60,8 @@ def test_request_with_ssrf_proxy(mocker: MockerFixture): assert mock_transport.call_count == 2 -def test_request_with_only_one_proxy_config(mocker: MockerFixture): - # Mock dify_config with only one proxy - mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080") - mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", None) +def test_request_with_only_one_proxy_config(mocker: MockerFixture, config_overrides: Callable[..., None]): + config_overrides(SSRF_PROXY_HTTP_URL="http://proxy:8080", SSRF_PROXY_HTTPS_URL=None) # Mock httpx.Client mock_client = mocker.MagicMock() diff --git a/api/tests/unit_tests/core/helper/test_marketplace.py b/api/tests/unit_tests/core/helper/test_marketplace.py index 6d9d37f4c93..a587584ae19 100644 --- a/api/tests/unit_tests/core/helper/test_marketplace.py +++ b/api/tests/unit_tests/core/helper/test_marketplace.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from types import SimpleNamespace from unittest.mock import MagicMock @@ -21,9 +22,11 @@ def test_get_plugin_pkg_url_contains_unique_identifier() -> None: assert "unique_identifier=langgenius%2Fopenai%3A0.4.2%40checksum" in url -def test_download_plugin_pkg_delegates_with_configured_size(mocker: MockerFixture) -> None: +def test_download_plugin_pkg_delegates_with_configured_size( + mocker: MockerFixture, config_overrides: Callable[..., None] +) -> None: mocked_download = mocker.patch("core.helper.marketplace.download_with_size_limit", return_value=b"pkg") - mocker.patch("core.helper.marketplace.dify_config.PLUGIN_MAX_PACKAGE_SIZE", 1234) + config_overrides(PLUGIN_MAX_PACKAGE_SIZE=1234) result = download_plugin_pkg("langgenius/openai:0.4.2@checksum") diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index 458f2efd05b..824db04c56d 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -1,4 +1,5 @@ import gzip +from collections.abc import Callable from typing import override from unittest.mock import ANY, MagicMock, call, patch @@ -141,15 +142,19 @@ def test_force_list_response_returns_when_retries_disabled(mock_get_client): mock_client.send.assert_called_once() -def test_build_ssrf_client_passes_ssl_verify_to_proxy_mount_transports(): +def test_build_ssrf_client_passes_ssl_verify_to_proxy_mount_transports( + config_overrides: Callable[..., None], +): + config_overrides( + SSRF_PROXY_ALL_URL=None, + SSRF_PROXY_HTTP_URL="http://proxy.example.com:8080", + SSRF_PROXY_HTTPS_URL="http://proxy.example.com:8443", + ) mock_client = MagicMock() http_transport = MagicMock() https_transport = MagicMock() with ( - patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_ALL_URL", None), - patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy.example.com:8080"), - patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_HTTPS_URL", "http://proxy.example.com:8443"), patch("core.helper.ssrf_proxy.httpx.HTTPTransport", side_effect=[http_transport, https_transport]) as transport, patch("core.helper.ssrf_proxy.httpx.Client", return_value=mock_client) as client, ): diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py index 2efef0b8fff..003514450dc 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py @@ -649,6 +649,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) def test_instruction_modify_workflow_rejects_app_from_another_tenant( @@ -670,6 +671,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) def test_instruction_modify_workflow_requires_draft_workflow( @@ -691,6 +693,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) def test_instruction_modify_workflow_uses_last_run( @@ -720,6 +723,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "workflow"} @@ -748,6 +752,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "workflow"} @@ -783,6 +788,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "fallback"} @@ -807,6 +813,7 @@ class TestLLMGenerator: model_config_entity, "ideal", workflow_service, + session=database, ) assert result == {"modified": "workflow"} diff --git a/api/tests/unit_tests/core/mcp/server/test_streamable_http.py b/api/tests/unit_tests/core/mcp/server/test_streamable_http.py index 42a9df3538a..1878fe08413 100644 --- a/api/tests/unit_tests/core/mcp/server/test_streamable_http.py +++ b/api/tests/unit_tests/core/mcp/server/test_streamable_http.py @@ -22,6 +22,7 @@ from core.mcp.server.streamable_http import ( ) from graphon.variables.input_entities import VariableEntity, VariableEntityType from models.model import App, AppMCPServer, AppMode, EndUser +from services.errors.app import TriggerWorkflowServiceModeUnavailableError class TestHandleMCPRequest: @@ -157,6 +158,29 @@ class TestHandleMCPRequest: # Verify AppGenerateService was called mock_app_generate.generate.assert_called_once() + @patch("core.mcp.server.streamable_http.AppGenerateService") + def test_handle_call_tool_returns_trigger_workflow_business_error(self, mock_app_generate): + mock_call_request = Mock(spec=types.CallToolRequest) + mock_call_request.params = Mock() + mock_call_request.params.arguments = {"query": "test question"} + mock_call_request.id = 123 + self.mock_request.root = mock_call_request + mock_app_generate.generate.side_effect = TriggerWorkflowServiceModeUnavailableError() + + result = handle_mcp_request( + Mock(), + self.app, + self.mock_request, + self.user_input_form, + self.mcp_server, + self.end_user, + 123, + ) + + assert isinstance(result, types.JSONRPCError) + assert result.error.code == types.INVALID_REQUEST + assert result.error.data == {"code": "trigger_workflow_service_mode_unavailable"} + @patch("core.mcp.server.streamable_http.AppGenerateService") def test_handle_call_tool_request_threads_protocol_version(self, mock_app_generate): """The negotiated version reaches handle_call_tool through the dispatcher.""" diff --git a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py index 3bb3827d629..f4bebc64c21 100644 --- a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py +++ b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py @@ -17,7 +17,6 @@ from sqlalchemy import Engine from sqlalchemy.orm import Session, sessionmaker import core.ops.ops_trace_manager as module -from configs import dify_config from core.ops.ops_trace_manager import OpsTraceManager, TraceQueueManager, TraceTask, TraceTaskName from core.rag.models.document import Document as RetrievalDocument from graphon.enums import WorkflowExecutionStatus @@ -26,6 +25,7 @@ from models.enums import ConversationFromSource, CreatorUserRole, MessageStatus, from models.model import App, AppMode, AppModelConfig, Conversation, Message, MessageFile, TraceAppConfig from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository +from tests.unit_tests.config_override import apply_config_overrides class DummyConfig: @@ -155,7 +155,7 @@ def database(sqlite_engine: Engine, sqlite_session: Session) -> Iterator[Session def trace_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setattr(module, "provider_config_map", FakeProviderMap({"dummy": PROVIDER_ENTRY})) monkeypatch.setattr(module, "unified_provider_config_map", FakeProviderMap({})) - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", False) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=False) OpsTraceManager.ops_trace_instances_cache.clear() OpsTraceManager.decrypted_configs_cache.clear() monkeypatch.setattr(module.threading, "Timer", DummyTimer) @@ -386,7 +386,7 @@ def test_ops_trace_instance_routes_by_unified_switch( app = _app(database, tracing=json.dumps({"enabled": True, "tracing_provider": "dummy"})) database.add(TraceAppConfig(app_id=app.id, tracing_provider="dummy", tracing_config={})) database.commit() - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", enabled) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=enabled) entries = {"dummy": UNIFIED_PROVIDER_ENTRY} if registered else {} monkeypatch.setattr(module, "unified_provider_config_map", FakeProviderMap(entries)) @@ -404,7 +404,7 @@ def test_registered_unified_provider_does_not_fallback_when_construction_fails( app = _app(database, tracing=json.dumps({"enabled": True, "tracing_provider": "dummy"})) database.add(TraceAppConfig(app_id=app.id, tracing_provider="dummy", tracing_config={})) database.commit() - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", True) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=True) monkeypatch.setattr( module, "unified_provider_config_map", @@ -433,9 +433,9 @@ def test_unified_and_legacy_instances_have_separate_cache_entries( database.commit() monkeypatch.setattr(module, "unified_provider_config_map", FakeProviderMap({"dummy": UNIFIED_PROVIDER_ENTRY})) - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", False) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=False) legacy = OpsTraceManager.get_ops_trace_instance(app.id) - monkeypatch.setattr(dify_config, "OPS_TRACE_UNIFIED_ENABLED", True) + apply_config_overrides(monkeypatch, OPS_TRACE_UNIFIED_ENABLED=True) unified = OpsTraceManager.get_ops_trace_instance(app.id) assert type(legacy) is DummyTraceInstance diff --git a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py index 3eff5112ec3..7ba72be423f 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py @@ -1,4 +1,5 @@ import json +from collections.abc import Callable from urllib.parse import quote import pytest @@ -42,9 +43,9 @@ class _StreamContext: class TestBasePluginClientImpl: - def test_inject_trace_headers(self, mocker: MockerFixture): + def test_inject_trace_headers(self, mocker: MockerFixture, config_overrides: Callable[..., None]): client = BasePluginClient() - mocker.patch("core.plugin.impl.base.dify_config.ENABLE_OTEL", True) + config_overrides(ENABLE_OTEL=True) trace_header = "00-abc-xyz-01" mocker.patch("core.helper.trace_id_helper.generate_traceparent_header", return_value=trace_header) diff --git a/api/tests/unit_tests/core/plugin/test_endpoint_client.py b/api/tests/unit_tests/core/plugin/test_endpoint_client.py index ff9deb918af..c8042275dfa 100644 --- a/api/tests/unit_tests/core/plugin/test_endpoint_client.py +++ b/api/tests/unit_tests/core/plugin/test_endpoint_client.py @@ -8,6 +8,7 @@ This test module covers the endpoint client operations including: Tests follow the Arrange-Act-Assert pattern for clarity. """ +from collections.abc import Callable from unittest.mock import MagicMock, patch import httpx @@ -42,13 +43,9 @@ class TestPluginEndpointClientDelete: return PluginEndpointClient() @pytest.fixture - def mock_config(self): + def mock_config(self, config_overrides: Callable[..., None]): """Mock plugin daemon configuration.""" - with ( - patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_URL", "http://127.0.0.1:5002"), - patch("core.plugin.impl.base.dify_config.PLUGIN_DAEMON_KEY", "test-api-key"), - ): - yield + config_overrides(PLUGIN_DAEMON_URL="http://127.0.0.1:5002", PLUGIN_DAEMON_KEY="test-api-key") def test_delete_endpoint_success(self, endpoint_client, mock_config): """Test successful endpoint deletion. diff --git a/api/tests/unit_tests/core/plugin/test_plugin_entities.py b/api/tests/unit_tests/core/plugin/test_plugin_entities.py index 3875ec70c5f..cb4eaf1cd88 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_entities.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_entities.py @@ -1,11 +1,11 @@ import binascii import datetime +from collections.abc import Callable from enum import StrEnum import pytest from flask import Response from pydantic import ValidationError -from pytest_mock import MockerFixture from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.entities.marketplace import MarketplacePluginDeclaration, MarketplacePluginSnapshot @@ -35,8 +35,8 @@ from graphon.model_runtime.entities.message_entities import ( class TestEndpointEntity: - def test_endpoint_entity_with_instance_renders_url(self, mocker: MockerFixture): - mocker.patch("core.plugin.entities.endpoint.dify_config.ENDPOINT_URL_TEMPLATE", "https://dify.test/{hook_id}") + def test_endpoint_entity_with_instance_renders_url(self, config_overrides: Callable[..., None]): + config_overrides(ENDPOINT_URL_TEMPLATE="https://dify.test/{hook_id}") now = datetime.datetime.now(datetime.UTC) entity = EndpointEntityWithInstance.model_validate( diff --git a/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py b/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py index e536c0831fd..6a5046154e8 100644 --- a/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py +++ b/api/tests/unit_tests/core/prompt/test_advanced_prompt_transform.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch import pytest -from configs import dify_config from core.app.app_config.entities import ModelConfigEntity from core.memory.token_buffer_memory import TokenBufferMemory from core.prompt.advanced_prompt_transform import AdvancedPromptTransform @@ -19,6 +18,7 @@ from graphon.model_runtime.entities.message_entities import ( UserPromptMessage, ) from models.model import Conversation +from tests.unit_tests.config_override import apply_config_overrides def test__get_completion_model_prompt_messages(): @@ -128,9 +128,9 @@ def test__get_chat_model_prompt_messages_no_memory(get_chat_model_args): ) -def test__get_chat_model_prompt_messages_with_files_no_memory(get_chat_model_args): +def test__get_chat_model_prompt_messages_with_files_no_memory(get_chat_model_args, monkeypatch: pytest.MonkeyPatch): model_config_mock, _, messages, inputs, context = get_chat_model_args - dify_config.MULTIMODAL_SEND_FORMAT = "url" + apply_config_overrides(monkeypatch, MULTIMODAL_SEND_FORMAT="url") files = [ File( diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py index eb96d33989b..835e6b54e87 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py @@ -289,7 +289,9 @@ def test_get_dataset_keyword_table_returns_existing_table_data(patched_runtime): def test_get_dataset_keyword_table_creates_table_when_missing(monkeypatch: pytest.MonkeyPatch, patched_runtime): keyword = Jieba(_dataset(dataset_keyword_table=None)) - monkeypatch.setattr(jieba_module.dify_config, "KEYWORD_DATA_SOURCE_TYPE", "database") + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, KEYWORD_DATA_SOURCE_TYPE="database") result = keyword._get_dataset_keyword_table(patched_runtime.session) assert result == {} diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py index 9fa76dd9737..9b392493eda 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py @@ -9,6 +9,7 @@ from core.rag.datasource.keyword.keyword_factory import Keyword from core.rag.datasource.keyword.keyword_type import KeyWordType from core.rag.models.document import Document from models.dataset import Dataset +from tests.unit_tests.config_override import apply_config_overrides def test_get_keyword_factory_returns_jieba_factory(monkeypatch: pytest.MonkeyPatch): @@ -38,7 +39,7 @@ def test_keyword_initialization_uses_configured_factory(monkeypatch: pytest.Monk ) fake_processor = MagicMock() - monkeypatch.setattr("core.rag.datasource.keyword.keyword_factory.dify_config.KEYWORD_STORE", KeyWordType.JIEBA) + apply_config_overrides(monkeypatch, KEYWORD_STORE=KeyWordType.JIEBA) monkeypatch.setattr(Keyword, "get_keyword_factory", staticmethod(lambda keyword_type: lambda _: fake_processor)) keyword = Keyword(dataset) diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index 47b917c86d5..dd78ae7fd14 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -14,6 +14,7 @@ from extensions.storage.storage_type import StorageType from models.dataset import Whitelist from models.enums import CreatorUserRole from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides def _register_fake_factory_module(monkeypatch: pytest.MonkeyPatch, module_path: str, class_name: str): @@ -268,8 +269,11 @@ def test_init_vector_uses_whitelist_override( tenant_id = str(uuid4()) sqlite_session.add(Whitelist(tenant_id=tenant_id, category="vector_db")) sqlite_session.commit() - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE", vector_factory_module.VectorType.CHROMA) - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE_WHITELIST_ENABLE", True) + apply_config_overrides( + monkeypatch, + VECTOR_STORE=vector_factory_module.VectorType.CHROMA, + VECTOR_STORE_WHITELIST_ENABLE=True, + ) monkeypatch.setattr( vector_factory_module.Vector, "get_vector_factory", @@ -290,8 +294,7 @@ def test_init_vector_uses_whitelist_override( def test_init_vector_raises_when_vector_store_missing( vector_factory_module, monkeypatch: pytest.MonkeyPatch, unbound_session: Session ): - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE", None) - monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE_WHITELIST_ENABLE", False) + apply_config_overrides(monkeypatch, VECTOR_STORE=None, VECTOR_STORE_WHITELIST_ENABLE=False) vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) vector._dataset = SimpleNamespace(index_struct_dict=None, tenant_id="tenant-1") diff --git a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py index af12b8780f6..2797d55d39f 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py @@ -9,6 +9,7 @@ import core.rag.extractor.excel_extractor as excel_module from core.rag.extractor.excel_extractor import ExcelExtractor from models.base import TypeBase from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -77,8 +78,7 @@ def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch): saves.append((key, data)) monkeypatch.setattr(excel_module.storage, "save", save) - monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") return saves diff --git a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py index 369e63e57e3..49c2cce919f 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py @@ -12,6 +12,7 @@ from core.rag.models.document import Document from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides def _upload_file(*, key: str, file_id: str = "upload-file-1") -> UploadFile: @@ -145,7 +146,7 @@ class TestExtractProcessorLoaders: content = "a" * 100_000 response = SimpleNamespace(headers={"Content-Type": "text/plain"}, content=content.encode()) monkeypatch.setattr(processor_module.remote_fetcher, "make_request", lambda *args, **kwargs: response) - monkeypatch.setattr(processor_module.dify_config, "ETL_TYPE", "SelfHosted") + apply_config_overrides(monkeypatch, ETL_TYPE="SelfHosted") text = ExtractProcessor.load_from_url("https://example.com/response.txt", return_text=True) @@ -155,8 +156,11 @@ class TestExtractProcessorLoaders: class TestExtractProcessorFileRouting: @pytest.fixture(autouse=True) def _set_unstructured_config(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(processor_module.dify_config, "UNSTRUCTURED_API_URL", "https://unstructured") - monkeypatch.setattr(processor_module.dify_config, "UNSTRUCTURED_API_KEY", "key") + apply_config_overrides( + monkeypatch, + UNSTRUCTURED_API_URL="https://unstructured", + UNSTRUCTURED_API_KEY="key", + ) def _run_extract_for_extension( self, @@ -167,7 +171,7 @@ class TestExtractProcessorFileRouting: session: object | None = None, ): factory = _patch_all_extractors(monkeypatch) - monkeypatch.setattr(processor_module.dify_config, "ETL_TYPE", etl_type) + apply_config_overrides(monkeypatch, ETL_TYPE=etl_type) def fake_download(key: str, local_path: str): Path(local_path).write_text("content", encoding="utf-8") diff --git a/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py index 8f49647fe7c..1f313659fd8 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py @@ -17,6 +17,7 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from models.base import TypeBase from models.dataset import Document as DocumentModel from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -74,7 +75,7 @@ class TestNotionExtractorInitAndPublicMethods: "_get_access_token", classmethod(lambda cls, tenant_id, credential_id: (_ for _ in ()).throw(Exception("credential error"))), ) - monkeypatch.setattr(notion_extractor.dify_config, "NOTION_INTEGRATION_TOKEN", "env-token", raising=False) + apply_config_overrides(monkeypatch, NOTION_INTEGRATION_TOKEN="env-token") extractor = notion_extractor.NotionExtractor( notion_workspace_id="ws", @@ -92,7 +93,7 @@ class TestNotionExtractorInitAndPublicMethods: "_get_access_token", classmethod(lambda cls, tenant_id, credential_id: (_ for _ in ()).throw(Exception("credential error"))), ) - monkeypatch.setattr(notion_extractor.dify_config, "NOTION_INTEGRATION_TOKEN", None, raising=False) + apply_config_overrides(monkeypatch, NOTION_INTEGRATION_TOKEN=None) with pytest.raises(ValueError, match="Must specify `integration_token`"): notion_extractor.NotionExtractor( diff --git a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py index 3f5cf0d37cb..bc35f5fde03 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session import core.rag.extractor.pdf_extractor as pe from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides TENANT_ID = str(uuid4()) USER_ID = str(uuid4()) @@ -41,9 +42,12 @@ def mock_dependencies(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) storage = _Storage() monkeypatch.setattr(pe, "storage", storage) monkeypatch.setattr(pe, "db", _DatabaseBinding(sqlite_session)) - monkeypatch.setattr(pe.dify_config, "FILES_URL", "http://files.local") - monkeypatch.setattr(pe.dify_config, "INTERNAL_FILES_URL", None) - monkeypatch.setattr(pe.dify_config, "STORAGE_TYPE", "local") + apply_config_overrides( + monkeypatch, + FILES_URL="http://files.local", + INTERNAL_FILES_URL=None, + STORAGE_TYPE="local", + ) return _Dependencies(storage=storage, session=sqlite_session) diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index 830e95c1721..211feb8dd13 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -21,6 +21,7 @@ from sqlalchemy.orm import Session import core.rag.extractor.word_extractor as we from core.rag.extractor.word_extractor import WordExtractor from models.model import UploadFile +from tests.unit_tests.config_override import apply_config_overrides class _TextOxmlElement(Protocol): @@ -131,8 +132,7 @@ def test_extract_images_from_docx(monkeypatch: pytest.MonkeyPatch, inject_sessio monkeypatch.setattr(we, "db", db_stub) # Patch config values used for URL composition and storage type - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") # Patch external image fetcher def fake_make_request(method: str, url: str, **kwargs): @@ -208,8 +208,7 @@ def test_extract_images_does_not_stage_partial_files_on_storage_failure( ) save = MagicMock(side_effect=[None, RuntimeError("storage failure")]) monkeypatch.setattr(we, "storage", SimpleNamespace(save=save)) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") extractor = object.__new__(WordExtractor) extractor.tenant_id = "00000000-0000-0000-0000-000000000001" @@ -222,35 +221,24 @@ def test_extract_images_does_not_stage_partial_files_on_storage_failure( assert sqlite_session.scalars(select(UploadFile)).all() == [] -def test_extract_images_from_docx_uses_internal_files_url(): +def test_extract_images_from_docx_uses_internal_files_url(monkeypatch: pytest.MonkeyPatch): """Test that INTERNAL_FILES_URL takes precedence over FILES_URL for plugin access.""" # Test the URL generation logic directly from configs import dify_config - # Mock the configuration values - original_files_url = dify_config.FILES_URL - original_internal_files_url = dify_config.INTERNAL_FILES_URL + apply_config_overrides( + monkeypatch, + FILES_URL="http://external.example.com", + INTERNAL_FILES_URL="http://internal.docker:5001", + ) - try: - # Set both URLs - INTERNAL should take precedence - dify_config.FILES_URL = "http://external.example.com" - dify_config.INTERNAL_FILES_URL = "http://internal.docker:5001" + upload_file_id = "test_file_id" - # Test the URL generation logic (same as in word_extractor.py) - upload_file_id = "test_file_id" + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + generated_url = f"{base_url}/files/{upload_file_id}/file-preview" - # This is the pattern we fixed in the word extractor - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL - generated_url = f"{base_url}/files/{upload_file_id}/file-preview" - - # Verify that INTERNAL_FILES_URL is used instead of FILES_URL - assert "http://internal.docker:5001" in generated_url, f"Expected internal URL, got: {generated_url}" - assert "http://external.example.com" not in generated_url, f"Should not use external URL, got: {generated_url}" - - finally: - # Restore original values - dify_config.FILES_URL = original_files_url - dify_config.INTERNAL_FILES_URL = original_internal_files_url + assert "http://internal.docker:5001" in generated_url, f"Expected internal URL, got: {generated_url}" + assert "http://external.example.com" not in generated_url, f"Should not use external URL, got: {generated_url}" def test_extract_hyperlinks(monkeypatch: pytest.MonkeyPatch, unbound_session: Session): @@ -258,8 +246,7 @@ def test_extract_hyperlinks(monkeypatch: pytest.MonkeyPatch, unbound_session: Se monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda k, d: None)) db_stub = SimpleNamespace(session=unbound_session) monkeypatch.setattr(we, "db", db_stub) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") doc = Document() p = doc.add_paragraph("Visit ") @@ -303,8 +290,7 @@ def test_extract_legacy_hyperlinks(monkeypatch: pytest.MonkeyPatch, unbound_sess monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda k, d: None)) db_stub = SimpleNamespace(session=unbound_session) monkeypatch.setattr(we, "db", db_stub) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) - monkeypatch.setattr(we.dify_config, "STORAGE_TYPE", "local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local", STORAGE_TYPE="local") doc = Document() p = doc.add_paragraph() @@ -476,7 +462,7 @@ def test_extract_images_handles_invalid_external_cases(monkeypatch: pytest.Monke db_stub = SimpleNamespace(session=sqlite_session) monkeypatch.setattr(we, "db", db_stub) monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda key, data: None)) - monkeypatch.setattr(we.dify_config, "FILES_URL", "http://files.local", raising=False) + apply_config_overrides(monkeypatch, FILES_URL="http://files.local") extractor = object.__new__(WordExtractor) extractor.tenant_id = "tenant" diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py index cb3dc7b23da..2919c71e0a0 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_parent_child_index_processor.py @@ -14,6 +14,7 @@ from core.rag.models.document import AttachmentDocument, ChildDocument, Document from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentCreatedFrom, DocumentSegment from models.dataset import Document as DatasetDocument from models.enums import DataSourceType +from tests.unit_tests.config_override import config_overrides_context class TestParentChildIndexProcessor: @@ -206,10 +207,7 @@ class TestParentChildIndexProcessor: "core.rag.index_processor.processor.parent_child_index_processor.helper.generate_text_hash", return_value="hash", ), - patch( - "core.rag.index_processor.processor.parent_child_index_processor.dify_config.CHILD_CHUNKS_PREVIEW_NUMBER", - 2, - ), + config_overrides_context(CHILD_CHUNKS_PREVIEW_NUMBER=2), ): result = processor.transform( docs, diff --git a/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py b/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py index ad8fb37ea67..6dc66e5ff59 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py +++ b/api/tests/unit_tests/core/rag/indexing/test_index_processor_base.py @@ -16,6 +16,7 @@ from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import UploadFile from models.tools import ToolFile +from tests.unit_tests.config_override import config_overrides_context def _persist_upload(session: Session, *, upload_id: str, name: str) -> UploadFile: @@ -115,9 +116,7 @@ class TestBaseIndexProcessor: processor.format_preview([]) def test_get_splitter_validates_custom_length(self, processor: _ForwardingBaseIndexProcessor) -> None: - with patch( - "core.rag.index_processor.index_processor_base.dify_config.INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH", 1000 - ): + with config_overrides_context(INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=1000): with pytest.raises(ValueError, match="between 50 and 1000"): processor._get_splitter("custom", 49, 0, "", None) with pytest.raises(ValueError, match="between 50 and 1000"): diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index 935b983decd..bb40efc0ae9 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -36,6 +36,7 @@ from models.provider import ( TenantPreferredModelProvider, ) from models.provider_ids import ModelProviderID +from tests.unit_tests.config_override import config_overrides_context def _build_provider_manager() -> ProviderManager: @@ -302,7 +303,7 @@ def test_to_system_configuration_uses_owned_session_for_cloud_credit_pools() -> paid_pool = SimpleNamespace(quota_used=0, quota_limit=0) with ( - patch.object(provider_manager_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD), patch( "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", {provider_entity.provider: _build_hosting_provider()}, diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index a2947dbd94a..a299fe465dc 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import threading -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import dataclass from datetime import datetime from types import SimpleNamespace @@ -956,10 +956,10 @@ def test_get_mcp_provider_controller_missing_raises(monkeypatch: pytest.MonkeyPa ToolManager.get_mcp_provider_controller("tenant-1", "mcp-1") -def test_generate_tool_icon_urls_for_builtin_and_plugin(): - with patch("core.tools.tool_manager.dify_config.CONSOLE_API_URL", "https://console.example.com"): - builtin_url = ToolManager.generate_builtin_tool_icon_url("time") - plugin_url = ToolManager.generate_plugin_tool_icon_url("tenant-1", "icon.svg") +def test_generate_tool_icon_urls_for_builtin_and_plugin(config_overrides: Callable[..., None]): + config_overrides(CONSOLE_API_URL="https://console.example.com") + builtin_url = ToolManager.generate_builtin_tool_icon_url("time") + plugin_url = ToolManager.generate_plugin_tool_icon_url("tenant-1", "icon.svg") assert builtin_url.endswith("/tool-provider/builtin/time/icon") assert "/plugin/icon" in plugin_url diff --git a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py index 081b1897455..d715a2633e5 100644 --- a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py +++ b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py @@ -4,6 +4,7 @@ import pytest from core.tools.utils import system_encryption as encryption from core.tools.utils.system_encryption import EncryptionError, SystemEncrypter +from tests.unit_tests.config_override import apply_config_overrides def test_system_encrypter_roundtrip(): @@ -36,7 +37,7 @@ def test_system_encrypter_raises_error_for_invalid_ciphertext(): def test_system_helpers_use_global_cached_instance(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(encryption, "_encrypter", None) - monkeypatch.setattr("core.tools.utils.system_encryption.dify_config.SECRET_KEY", "global-secret") + apply_config_overrides(monkeypatch, SECRET_KEY="global-secret") first = encryption.get_system_encrypter() second = encryption.get_system_encrypter() diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index 35ed9f1ad5a..1a067ca2089 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -17,10 +17,10 @@ from unittest.mock import MagicMock, patch import pytest from jinja2 import Template -from configs import dify_config from core.workflow.generator.runner import WorkflowGenerator, _find_planned_tool_entry from core.workflow.generator.tool_catalogue import ToolCatalogueEntry from core.workflow.generator.types import GraphDict +from tests.unit_tests.config_override import apply_config_overrides def _llm_result(text: str) -> MagicMock: @@ -456,7 +456,7 @@ class _ParallelBuilderModel: class TestParallelNodeBuilder: def test_builder_concurrency_caps_at_configured_workers(self, monkeypatch): - monkeypatch.setattr(dify_config, "WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS", 2) + apply_config_overrides(monkeypatch, WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=2) planner = { "title": "URL Summarizer", "description": "Summarize a URL.", @@ -512,7 +512,7 @@ class TestParallelNodeBuilder: assert [edge["source"] for edge in result["graph"]["edges"]] == ["node1", "node2"] def test_higher_worker_config_runs_all_builders_in_one_wave(self, monkeypatch): - monkeypatch.setattr(dify_config, "WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS", 5) + apply_config_overrides(monkeypatch, WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=5) planner = { "title": "URL Summarizer", "description": "Summarize a URL.", @@ -561,7 +561,7 @@ class TestParallelNodeBuilder: # One worker: node1's builder fails immediately, node2's blocks the # worker briefly, node3 sits in the queue. The failure must cancel # node3 before the worker frees up — no LLM call for it at all. - monkeypatch.setattr(dify_config, "WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS", 1) + apply_config_overrides(monkeypatch, WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS=1) planner = { "title": "x", "description": "x", diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 34b72677314..28cf8ab6ef4 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -39,6 +39,7 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -464,7 +465,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada def test_build_maps_agent_soul_shell_settings_to_shell_layer(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -673,7 +674,7 @@ def test_build_shell_layer_config_maps_cli_tool_inline_secret_value_to_env(): def test_builds_workflow_run_request_with_dify_plugin_tools_layer(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -1470,7 +1471,7 @@ def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul(): def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) + apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) result = WorkflowAgentRuntimeRequestBuilder().build(_context()) diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index a23a6236487..5feb62d45f4 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -22,6 +22,7 @@ from graphon.nodes import BuiltinNodeTypes from graphon.runtime import VariablePool from graphon.variables.variables import StringVariable from models.workflow import Workflow, WorkflowType +from tests.unit_tests.config_override import config_overrides_context def _build_typed_node_config(node_type: NodeType): @@ -99,8 +100,7 @@ class TestWorkflowEntryInit: observability_layer = sentinel.observability_layer with ( - patch.object(workflow_entry.dify_config, "DEBUG", True), - patch.object(workflow_entry.dify_config, "ENABLE_OTEL", False), + config_overrides_context(DEBUG=True, ENABLE_OTEL=False), patch.object(workflow_entry, "is_instrument_flag_enabled", return_value=True), patch.object(workflow_entry, "capture_current_context", return_value=sentinel.execution_context), patch.object(workflow_entry, "GraphEngine", return_value=graph_engine) as graph_engine_cls, diff --git a/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py b/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py index 67c8c8e827f..14303aef5d9 100644 --- a/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py +++ b/api/tests/unit_tests/events/event_handlers/test_queue_default_plugin_install_when_tenant_created.py @@ -5,6 +5,7 @@ import pytest from events.event_handlers import queue_default_plugin_install_when_tenant_created as handler_module from models.account import Tenant +from tests.unit_tests.config_override import apply_config_overrides def _tenant() -> Tenant: @@ -15,7 +16,7 @@ def _tenant() -> Tenant: def test_handle_skips_when_no_default_plugins_are_configured(monkeypatch: pytest.MonkeyPatch) -> None: delay = MagicMock() - monkeypatch.setattr(handler_module.dify_config, "NEW_USER_DEFAULT_PLUGIN_IDS", "") + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_PLUGIN_IDS="") monkeypatch.setattr(handler_module.install_default_plugins_task, "delay", delay) handler_module.handle(_tenant()) @@ -29,7 +30,7 @@ def test_handle_queues_configured_plugins(monkeypatch: pytest.MonkeyPatch) -> No "langgenius/openai", "langgenius/gemini", ] - monkeypatch.setattr(handler_module.dify_config, "NEW_USER_DEFAULT_PLUGIN_IDS", ",".join(plugins)) + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_PLUGIN_IDS=",".join(plugins)) monkeypatch.setattr(handler_module.install_default_plugins_task, "delay", delay) handler_module.handle(_tenant()) @@ -41,11 +42,7 @@ def test_handle_does_not_fail_tenant_creation_when_queue_is_unavailable( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - monkeypatch.setattr( - handler_module.dify_config, - "NEW_USER_DEFAULT_PLUGIN_IDS", - "langgenius/openai", - ) + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_PLUGIN_IDS="langgenius/openai") monkeypatch.setattr( handler_module.install_default_plugins_task, "delay", diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py index b83602e98cf..c00fbaae36c 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_generate_handler.py @@ -6,17 +6,18 @@ Test objectives: 2. Verify span attribute mapping correctness """ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom from extensions.otel.decorators.handlers.generate_handler import AppGenerateHandler from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes +from tests.unit_tests.config_override import config_overrides_context class TestAppGenerateHandler: """Core tests for AppGenerateHandler""" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) + @config_overrides_context(ENABLE_OTEL=True) def test_compatible_with_real_function_signature( self, tracer_provider_with_memory_exporter, mock_app_model, mock_account_user ): @@ -48,7 +49,7 @@ class TestAppGenerateHandler: assert "args" in arguments, "Handler uses args but parameter is missing" assert "streaming" in arguments, "Handler uses streaming but parameter is missing" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) + @config_overrides_context(ENABLE_OTEL=True) def test_all_span_attributes_set_correctly( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_app_model, mock_account_user ): diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py index 842e7f55e2f..3e312a665eb 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py @@ -6,10 +6,9 @@ Test objectives: 2. Verify span attribute mapping correctness """ -from unittest.mock import patch - from extensions.otel.decorators.handlers.workflow_app_runner_handler import WorkflowAppRunnerHandler from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes +from tests.unit_tests.config_override import config_overrides_context class TestWorkflowAppRunnerHandler: @@ -41,7 +40,7 @@ class TestWorkflowAppRunnerHandler: for field in required_config_fields: assert field in config_fields, f"Handler expects app_config.{field} but field is missing" - @patch("extensions.otel.decorators.base.dify_config.ENABLE_OTEL", True) + @config_overrides_context(ENABLE_OTEL=True) def test_all_span_attributes_set_correctly( self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_workflow_runner ): diff --git a/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py b/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py index 09f0d9dc4ff..bdd418e47e4 100644 --- a/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py +++ b/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py @@ -1,10 +1,11 @@ import threading from collections.abc import Callable -from unittest.mock import MagicMock, patch +from unittest.mock import patch from uuid import uuid4 import pytest from opentelemetry.trace import StatusCode, get_current_span, get_tracer +from sqlalchemy.orm import Session from core.rag.rerank.rerank_type import RerankMode from core.rag.retrieval.dataset_retrieval import DatasetRetrieval @@ -20,6 +21,7 @@ def _otel_enabled(config_overrides: Callable[..., None]) -> None: def test_knowledge_retrieval_creates_a_child_otel_span( memory_span_exporter, tracer_provider_with_memory_exporter, + sqlite_session: Session, ) -> None: """The retrieval entry point must be visible beneath its workflow node span.""" request = KnowledgeRetrievalRequest( @@ -38,7 +40,7 @@ def test_knowledge_retrieval_creates_a_child_otel_span( patch.object(retrieval, "_get_available_datasets", return_value=[]), get_tracer(__name__).start_as_current_span("knowledge-retrieval-node") as node_span, ): - assert retrieval.knowledge_retrieval(MagicMock(), request) == [] + assert retrieval.knowledge_retrieval(sqlite_session, request) == [] retrieval_span = next( span @@ -101,7 +103,6 @@ def test_retriever_thread_exception_sets_error_span_and_is_collected( expected_error = RuntimeError("retrieval failed") with ( - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"), patch.object(retrieval, "_retriever", side_effect=expected_error), ): retrieval._run_retriever_thread_safely( @@ -139,7 +140,6 @@ def test_retriever_thread_exception_emits_skip_event_when_requested( dataset_id = str(uuid4()) with ( - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"), patch.object(retrieval, "_retriever", side_effect=expected_error), get_tracer(__name__).start_as_current_span("dataset-retrieval-parent") as parent_span, ): diff --git a/api/tests/unit_tests/extensions/otel/test_runtime.py b/api/tests/unit_tests/extensions/otel/test_runtime.py index d1038d30eb9..1bf4aff293a 100644 --- a/api/tests/unit_tests/extensions/otel/test_runtime.py +++ b/api/tests/unit_tests/extensions/otel/test_runtime.py @@ -6,6 +6,7 @@ from opentelemetry.sdk.trace import TracerProvider from core.logging.context import clear_request_context from models import Account +from tests.unit_tests.config_override import config_overrides_context def _user() -> Account: @@ -29,7 +30,7 @@ def test_on_user_loaded_does_not_write_to_non_recording_span() -> None: user = _user() with ( - mock.patch.object(runtime.dify_config, "ENABLE_OTEL", True), + config_overrides_context(ENABLE_OTEL=True), mock.patch("opentelemetry.trace.get_current_span", return_value=span), mock.patch.object(runtime, "extract_tenant_id", return_value="tenant-id"), ): @@ -49,7 +50,7 @@ def test_on_user_loaded_sets_attributes_on_recording_span() -> None: user = _user() with ( - mock.patch.object(runtime.dify_config, "ENABLE_OTEL", True), + config_overrides_context(ENABLE_OTEL=True), mock.patch("opentelemetry.trace.get_current_span", return_value=span), mock.patch.object(runtime, "extract_tenant_id", return_value="tenant-id"), ): @@ -73,7 +74,7 @@ def test_on_user_loaded_ignores_ended_sdk_span(caplog) -> None: with ( trace.use_span(span, end_on_exit=False), - mock.patch.object(runtime.dify_config, "ENABLE_OTEL", True), + config_overrides_context(ENABLE_OTEL=True), mock.patch.object(runtime, "extract_tenant_id", return_value="tenant-id"), caplog.at_level("WARNING", logger="opentelemetry.sdk.trace"), ): diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index e5cf0b3b82c..191f13cf7e3 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -32,6 +32,12 @@ from services.account_activation_adapters import ( RegisterServiceInvitationTokenStore, ) from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway +from services.account_email_registration_adapters import ( + AccountServiceRegistrationGateway, + BillingAccountRegistrationPolicyGateway, + RedisEmailRegistrationSecurityGateway, + TokenManagerEmailRegistrationTokenGateway, +) from services.app_site_service import AppSiteService from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService from services.billing_portal_service import BillingPortalService @@ -46,6 +52,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi from services.tag_application_service import TagApplicationService from services.webapp_access_query_service import WebAppAccessUnavailableError from services.workflow_statistic_query_service import WorkflowStatisticQueryService +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize( @@ -103,12 +110,11 @@ def test_init_app_registers_services_for_the_current_app( ) -> None: app = Flask(__name__) monkeypatch.setattr(ext_application_services, "get_session_maker", lambda: sqlite_session_factory) - monkeypatch.setattr( - ext_application_services.dify_config, - "DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, + INIT_PASSWORD="expected", ) - monkeypatch.setattr(ext_application_services.dify_config, "INIT_PASSWORD", "expected") ext_application_services.init_app(app) @@ -367,8 +373,17 @@ def test_build_application_services_wires_account_profile_repository( assert services.accounts.initialization._accounts is accounts assert not services.accounts.initialization._invitation_required assert services.accounts.change_email._accounts is accounts + email_registration = services.accounts.email_registration + assert email_registration._accounts is accounts + assert isinstance(email_registration._tokens, TokenManagerEmailRegistrationTokenGateway) + assert isinstance(email_registration._security, RedisEmailRegistrationSecurityGateway) + assert isinstance(email_registration._account_policy, BillingAccountRegistrationPolicyGateway) + assert isinstance(email_registration._registration, AccountServiceRegistrationGateway) + assert email_registration._registration._session_factory is sqlite_session_factory assert services.accounts.education._accounts is accounts assert services.accounts.deletion._accounts is accounts + assert services.notifications._accounts is accounts + assert services.step_by_step_tour._accounts is accounts assert services.accounts.deletion._memberships is services.workspace_queries._workspaces integrations = services.accounts.integrations._integrations assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository) @@ -618,7 +633,7 @@ def test_build_application_services_wires_dynamic_recommended_catalog( sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(ext_application_services.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin") services = ext_application_services.build_application_services( database_client=sqlite_session_factory, deployment_edition=DeploymentEdition.COMMUNITY, @@ -643,7 +658,7 @@ def test_build_application_services_wires_dynamic_recommended_catalog( ) assert result.recommended_apps - monkeypatch.setattr(ext_application_services.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "invalid") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="invalid") with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"): services.recommended_app_queries.list_recommended( requested_language="en-US", diff --git a/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py b/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py index 602cd8f019c..43d768974fd 100644 --- a/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py +++ b/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py @@ -32,9 +32,9 @@ from collections.abc import Iterator import pytest from flask import Blueprint -from configs import dify_config from dify_app import DifyApp from extensions import ext_blueprints +from tests.unit_tests.config_override import apply_config_overrides # Modules whose `bp` attribute is consumed by `ext_blueprints.init_app`. # Keep in sync with the imports inside `init_app`. @@ -90,7 +90,7 @@ def test_openapi_blueprint_registered_with_cors_when_enabled( fresh_blueprints: dict[str, Blueprint], ) -> None: """Enabled gate: blueprint mounted, CORS wired, `/openapi/v1/*` rules live.""" - monkeypatch.setattr(dify_config, "OPENAPI_ENABLED", True) + apply_config_overrides(monkeypatch, OPENAPI_ENABLED=True) app = _build_app() ext_blueprints.init_app(app) @@ -111,7 +111,7 @@ def test_openapi_blueprint_absent_when_disabled( fresh_blueprints: dict[str, Blueprint], ) -> None: """Disabled gate: no blueprint, no CORS, no `/openapi/v1/*` URL rules.""" - monkeypatch.setattr(dify_config, "OPENAPI_ENABLED", False) + apply_config_overrides(monkeypatch, OPENAPI_ENABLED=False) app = _build_app() ext_blueprints.init_app(app) diff --git a/api/tests/unit_tests/extensions/test_ext_request_logging.py b/api/tests/unit_tests/extensions/test_ext_request_logging.py index 664de8cbd8b..38787ac63a2 100644 --- a/api/tests/unit_tests/extensions/test_ext_request_logging.py +++ b/api/tests/unit_tests/extensions/test_ext_request_logging.py @@ -6,9 +6,9 @@ from unittest.mock import MagicMock import pytest from flask import Flask, Response -from configs import dify_config from extensions import ext_request_logging from extensions.ext_request_logging import _is_content_type_json, _log_request_finished, init_app +from tests.unit_tests.config_override import apply_config_overrides def test_is_content_type_json(): @@ -59,7 +59,7 @@ def mock_response_receiver(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: @pytest.fixture def enable_request_logging(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(dify_config, "ENABLE_REQUEST_LOGGING", True) + apply_config_overrides(monkeypatch, ENABLE_REQUEST_LOGGING=True) def _captured_records(caplog: pytest.LogCaptureFixture, level: int) -> list[logging.LogRecord]: @@ -77,7 +77,7 @@ class TestRequestLoggingExtension: mock_request_receiver: MagicMock, mock_response_receiver: MagicMock, ): - monkeypatch.setattr(dify_config, "ENABLE_REQUEST_LOGGING", False) + apply_config_overrides(monkeypatch, ENABLE_REQUEST_LOGGING=False) app = _get_test_app() init_app(app) diff --git a/api/tests/unit_tests/extensions/test_pubsub_channel.py b/api/tests/unit_tests/extensions/test_pubsub_channel.py index 2884509d22b..c63e29c3cbd 100644 --- a/api/tests/unit_tests/extensions/test_pubsub_channel.py +++ b/api/tests/unit_tests/extensions/test_pubsub_channel.py @@ -1,13 +1,13 @@ import pytest -from configs import dify_config from extensions import ext_redis from libs.broadcast_channel.redis.pubsub_channel import BroadcastChannel as RedisBroadcastChannel from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel +from tests.unit_tests.config_override import apply_config_overrides def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="pubsub") monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) channel = ext_redis.get_pubsub_broadcast_channel() @@ -16,7 +16,7 @@ def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch: pytest.Mon def test_get_pubsub_broadcast_channel_sharded(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "sharded") + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="sharded") monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) channel = ext_redis.get_pubsub_broadcast_channel() diff --git a/api/tests/unit_tests/extensions/test_set_secretkey.py b/api/tests/unit_tests/extensions/test_set_secretkey.py index 8a8e4e2b190..b49ff7107b8 100644 --- a/api/tests/unit_tests/extensions/test_set_secretkey.py +++ b/api/tests/unit_tests/extensions/test_set_secretkey.py @@ -4,6 +4,7 @@ import pytest from flask import Flask from extensions import ext_set_secretkey +from tests.unit_tests.config_override import apply_config_overrides class InMemoryStorage: @@ -25,7 +26,7 @@ class InMemoryStorage: def test_init_app_uses_configured_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: secret_key = "configured-secret-key" storage = InMemoryStorage() - monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", secret_key) + apply_config_overrides(monkeypatch, SECRET_KEY=secret_key) monkeypatch.setattr("configs.secret_key.storage", storage) app = Flask(__name__) app.config["SECRET_KEY"] = secret_key @@ -41,7 +42,7 @@ def test_init_app_generates_and_persists_secret_key_when_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: storage = InMemoryStorage() - monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + apply_config_overrides(monkeypatch, SECRET_KEY="") monkeypatch.setattr("configs.secret_key.storage", storage) app = Flask(__name__) app.config["SECRET_KEY"] = "" @@ -61,7 +62,7 @@ def test_init_app_reuses_persisted_secret_key_when_missing( ) -> None: persisted_key = "persisted-secret-key" storage = InMemoryStorage({".dify_secret_key": f"{persisted_key}\n".encode()}) - monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + apply_config_overrides(monkeypatch, SECRET_KEY="") monkeypatch.setattr("configs.secret_key.storage", storage) app = Flask(__name__) app.config["SECRET_KEY"] = "" diff --git a/api/tests/unit_tests/libs/test_archive_storage.py b/api/tests/unit_tests/libs/test_archive_storage.py index f42bc63d5f4..4c0d51a1907 100644 --- a/api/tests/unit_tests/libs/test_archive_storage.py +++ b/api/tests/unit_tests/libs/test_archive_storage.py @@ -12,6 +12,7 @@ from libs.archive_storage import ( ArchiveStorageError, ArchiveStorageNotConfiguredError, ) +from tests.unit_tests.config_override import apply_config_overrides BUCKET_NAME = "archive-bucket" @@ -26,8 +27,7 @@ def _configure_storage(monkeypatch: pytest.MonkeyPatch, **overrides): "ARCHIVE_STORAGE_REGION": "auto", } defaults.update(overrides) - for key, value in defaults.items(): - monkeypatch.setattr(storage_module.dify_config, key, value, raising=False) + apply_config_overrides(monkeypatch, **defaults) def _client_error(code: str) -> ClientError: diff --git a/api/tests/unit_tests/libs/test_login.py b/api/tests/unit_tests/libs/test_login.py index 8155dbd4c9d..420b640eb0c 100644 --- a/api/tests/unit_tests/libs/test_login.py +++ b/api/tests/unit_tests/libs/test_login.py @@ -10,6 +10,7 @@ import libs.login as login_module from extensions.ext_login import DifyLoginManager from libs.login import current_user from models.account import Account, Tenant +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -72,7 +73,7 @@ def login_app(mocker: MockerFixture) -> Flask: @pytest.fixture(autouse=True) def reset_login_disabled(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(login_module.dify_config, "LOGIN_DISABLED", False) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=False) @pytest.fixture @@ -184,7 +185,7 @@ class TestLoginRequired: """Test that bypass conditions skip auth lookup, CSRF, and unauthorized handling.""" resolve_user = resolve_current_user(MockUser("test_user")) - monkeypatch.setattr(login_module.dify_config, "LOGIN_DISABLED", login_disabled) + apply_config_overrides(monkeypatch, LOGIN_DISABLED=login_disabled) with login_app.test_request_context(method=method): result = protected_view() diff --git a/api/tests/unit_tests/libs/test_workspace_member_helper.py b/api/tests/unit_tests/libs/test_workspace_member_helper.py index d35a83e6430..6a202e79fa4 100644 --- a/api/tests/unit_tests/libs/test_workspace_member_helper.py +++ b/api/tests/unit_tests/libs/test_workspace_member_helper.py @@ -16,6 +16,7 @@ from enums import DeploymentEdition from libs import oauth_bearer from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, require_workspace_member from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from tests.unit_tests.config_override import apply_config_overrides pytestmark = pytest.mark.usefixtures("community_edition") @@ -47,7 +48,7 @@ def database(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Iterat @pytest.fixture def community_edition(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(oauth_bearer.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) def _ctx( @@ -100,7 +101,7 @@ def _account(account_id: uuid.UUID, *, status: AccountStatus = AccountStatus.ACT def test_skips_for_enterprise_edition(database: Database, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(oauth_bearer.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) before = len(database.statements) require_workspace_member(_ctx(), "tenant-1") diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index e724b6e0e86..5c058c42b10 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -47,6 +47,7 @@ from models.enums import ( SegmentStatus, ) from models.model import App, AppMode, IconType, UploadFile +from tests.unit_tests.config_override import apply_config_overrides def _make_dataset( @@ -1171,9 +1172,12 @@ class TestDocumentSegmentIndexing: monkeypatch.setattr("models.dataset.time.time", lambda: 1700000000) monkeypatch.setattr("models.dataset.os.urandom", lambda _: b"\x01" * 16) - monkeypatch.setattr("models.dataset.dify_config.SECRET_KEY", "unit-secret") - monkeypatch.setattr("models.dataset.dify_config.FILES_URL", "https://files.example.com") - monkeypatch.setattr("models.dataset.dify_config.CONSOLE_API_URL", "https://console.example.com") + apply_config_overrides( + monkeypatch, + SECRET_KEY="unit-secret", + FILES_URL="https://files.example.com", + CONSOLE_API_URL="https://console.example.com", + ) # Act attachments = segment.get_attachments(session=sqlite_session) diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py index ed6d4ed88c7..4e77e3e80cb 100644 --- a/api/tests/unit_tests/models/test_workflow.py +++ b/api/tests/unit_tests/models/test_workflow.py @@ -175,7 +175,7 @@ def test_to_dict(): @pytest.mark.parametrize("sqlite_session", [(Workflow, Account)], indirect=True) -def test_workflow_account_getters_use_caller_session(sqlite_session: Session): +def test_workflow_account_accessors_use_caller_session(sqlite_session: Session): created_account = Account(name="Created Account", email="created@example.com") created_account.id = "created-account-id" updated_account = Account(name="Updated Account", email="updated@example.com") @@ -197,12 +197,12 @@ def test_workflow_account_getters_use_caller_session(sqlite_session: Session): sqlite_session.add_all([decoy_account, updated_account, workflow, created_account]) sqlite_session.flush() - assert workflow.get_created_by_account(session=sqlite_session) is created_account - assert workflow.get_updated_by_account(session=sqlite_session) is updated_account + assert workflow.created_by_account(sqlite_session) is created_account + assert workflow.updated_by_account(sqlite_session) is updated_account @pytest.mark.parametrize("sqlite_session", [(Workflow, WorkflowToolProvider)], indirect=True) -def test_workflow_tool_published_getter_uses_caller_session(sqlite_session: Session): +def test_workflow_tool_published_accessor_uses_caller_session(sqlite_session: Session): workflow = Workflow( tenant_id="tenant_id", app_id="app_id", @@ -237,7 +237,8 @@ def test_workflow_tool_published_getter_uses_caller_session(sqlite_session: Sess sqlite_session.add_all([decoy_provider, workflow, matching_provider]) sqlite_session.flush() - assert workflow.get_tool_published(session=sqlite_session) is True + with pytest.warns(DeprecationWarning, match="not accurate"): + assert workflow.tool_published(sqlite_session) is True def test_normalize_environment_variable_mappings_converts_full_mask_to_hidden_value(): diff --git a/api/tests/unit_tests/repositories/test_account_repository.py b/api/tests/unit_tests/repositories/test_account_repository.py index daab5e6e7c3..945d2c7ddd6 100644 --- a/api/tests/unit_tests/repositories/test_account_repository.py +++ b/api/tests/unit_tests/repositories/test_account_repository.py @@ -97,6 +97,20 @@ def test_account_repository_updates_password( assert persisted.password_salt == "new-salt" +def test_account_repository_finds_email_with_lowercase_fallback( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_account(sqlite_session) + repository = SQLAlchemyAccountRepository(sqlite_session_factory) + + account = repository.find_by_email("Account@Example.com") + + assert account is not None + assert account.id == "account-1" + assert account.email == "account@example.com" + + def test_account_integration_repository_lists_integrations( sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], diff --git a/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py b/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py new file mode 100644 index 00000000000..a6439f58bc5 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py @@ -0,0 +1,171 @@ +from contextlib import nullcontext +from dataclasses import replace +from datetime import datetime +from typing import cast +from unittest.mock import MagicMock, Mock + +import pytest +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from models.onboarding import AccountStepByStepTourState +from repositories.step_by_step_tour_repository import ( + SQLAlchemyStepByStepTourStateRepository, + _is_retryable_mysql_lock_error, +) + + +class _ErrnoOnlyError(Exception): + def __init__(self, errno: int | str) -> None: + super().__init__() + self.errno = errno + + +def test_mutate_creates_and_updates_state_in_repository_owned_transaction( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + + saved = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=("home",)), + ) + reloaded = repository.get("account-1") + + assert saved.first_workspace_id is None + assert saved.completed_task_ids == ("home",) + assert saved.updated_at is not None + assert reloaded == saved + + +def test_initialize_creates_state_with_first_workspace_atomically( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + + result = repository.initialize("account-1", "workspace-1") + + assert result.first_workspace_id == "workspace-1" + assert repository.get("account-1") == result + + +def test_initialize_claims_empty_state_once_without_overwriting_winner( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + with sqlite_session_factory() as session: + session.add(AccountStepByStepTourState(account_id="account-1")) + session.commit() + + first = repository.initialize("account-1", "workspace-1") + second = repository.initialize("account-1", "workspace-2") + + assert first.first_workspace_id == "workspace-1" + assert second.first_workspace_id == "workspace-1" + + +def test_mutate_cannot_clear_or_overwrite_first_workspace( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + repository.initialize("account-1", "workspace-1") + + result = repository.mutate( + "account-1", + lambda state: replace(state, first_workspace_id="workspace-2", skipped=True), + ) + + assert result.first_workspace_id == "workspace-1" + assert result.skipped is True + + +def test_sequential_mutations_replay_against_latest_state( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory) + + repository.mutate("account-1", lambda state: replace(state, completed_task_ids=("home",))) + result = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")), + ) + + assert result.completed_task_ids == ("home", "studio") + + +def test_mutate_replays_after_concurrent_create_conflict() -> None: + concurrent_state = AccountStepByStepTourState(account_id="account-1") + concurrent_state.completed_task_ids = ["home"] + concurrent_state.updated_at = datetime(2026, 8, 13) + session = MagicMock(spec=Session) + session.execute.return_value.scalar_one_or_none.side_effect = [None, concurrent_state] + session.flush.side_effect = IntegrityError("insert", {}, Exception("duplicate")) + factory = cast(sessionmaker[Session], Mock(return_value=nullcontext(session))) + repository = SQLAlchemyStepByStepTourStateRepository(factory) + + result = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")), + ) + + assert result.completed_task_ids == ("home", "studio") + session.rollback.assert_called_once_with() + initial_probe = session.execute.call_args_list[0].args[0] + replay_statement = session.execute.call_args_list[1].args[0] + assert initial_probe._for_update_arg is None + assert replay_statement._for_update_arg is not None + + +def test_mutate_retries_mysql_deadlock_with_fresh_session() -> None: + concurrent_state = AccountStepByStepTourState(account_id="account-1") + concurrent_state.completed_task_ids = ["home"] + concurrent_state.updated_at = datetime(2026, 8, 13) + + deadlocked_session = MagicMock(spec=Session) + deadlocked_session.execute.return_value.scalar_one_or_none.return_value = None + deadlocked_session.flush.side_effect = OperationalError( + "INSERT", + {}, + Exception(1213, "Deadlock found when trying to get lock"), + ) + + retry_session = MagicMock(spec=Session) + retry_session.execute.return_value.scalar_one_or_none.side_effect = [concurrent_state, concurrent_state] + factory = Mock(side_effect=[nullcontext(deadlocked_session), nullcontext(retry_session)]) + repository = SQLAlchemyStepByStepTourStateRepository(cast(sessionmaker[Session], factory)) + + result = repository.mutate( + "account-1", + lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")), + ) + + assert result.completed_task_ids == ("home", "studio") + assert factory.call_count == 2 + retry_lock_statement = retry_session.execute.call_args_list[1].args[0] + assert retry_lock_statement._for_update_arg is not None + + +@pytest.mark.parametrize( + ("orig", "expected"), + [ + pytest.param(_ErrnoOnlyError(1205), True, id="errno-attribute"), + pytest.param(Exception(1213, "deadlock"), True, id="integer-args-code"), + pytest.param(Exception("1213", "deadlock"), True, id="string-args-code"), + pytest.param(Exception(9999, "other error"), False, id="non-retryable-code"), + pytest.param(Exception(True), False, id="boolean-is-not-an-error-code"), + pytest.param(Exception(), False, id="missing-error-code"), + ], +) +def test_mysql_lock_error_detection_preserves_errno_and_args_coverage( + orig: BaseException, + expected: bool, +) -> None: + exc = OperationalError("statement", {}, orig) + + assert _is_retryable_mysql_lock_error(exc) is expected + + +def test_get_returns_none_for_unknown_account( + sqlite_session_factory: sessionmaker[Session], +) -> None: + assert SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory).get("missing") is None diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py index 3cf2bcd6372..7c0d5ef5332 100644 --- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py @@ -4,10 +4,14 @@ from unittest.mock import Mock import pytest from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session from graphon.enums import BuiltinNodeTypes from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -191,7 +195,9 @@ def test_agent_package_rejects_null_file_id_for_available_assets(asset: dict) -> AgentPackage.model_validate(package) -def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch: pytest.MonkeyPatch) -> None: +def test_import_warnings_cover_runtime_setup_removed_from_package( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: soul = AgentSoulConfig.model_validate( { "tools": { @@ -211,7 +217,7 @@ def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch: p ) monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", Mock(return_value={})) - _, warnings = AgentDslService(Mock())._resolve_package_soul( + _, warnings = AgentDslService(unbound_session)._resolve_package_soul( tenant_id="tenant-1", package=make_portable_agent_package(_agent(), soul), package_path="agent_packages.agent_1", @@ -231,23 +237,29 @@ def test_agent_package_rejects_unknown_schema_version() -> None: AgentPackage.model_validate(package) -def test_export_agent_app_requires_backing_agent() -> None: - session = Mock() - session.scalar.return_value = None - +def test_export_agent_app_requires_backing_agent(sqlite_session: Session) -> None: with pytest.raises(ValueError, match="no active backing Agent"): - AgentDslService(session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1")) + AgentDslService(sqlite_session).export_agent_app(app=SimpleNamespace(tenant_id="tenant-1", id="app-1")) @pytest.mark.parametrize("use_draft", [True, False]) -def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None: +def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool, sqlite_session: Session) -> None: agent = _agent() + agent.app_id = "app-1" agent.active_config_snapshot_id = "snapshot-1" - draft = SimpleNamespace(config_snapshot_dict=AgentSoulConfig(config_note="draft").model_dump(mode="json")) - session = Mock() - session.scalar.side_effect = [agent, draft if use_draft else None] - session.execute.return_value = [] - service = AgentDslService(session) + sqlite_session.add(agent) + if use_draft: + sqlite_session.add( + AgentConfigDraft( + tenant_id="tenant-1", + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + config_snapshot=AgentSoulConfig(config_note="draft"), + ) + ) + sqlite_session.flush() + service = AgentDslService(sqlite_session) require_snapshot = Mock(return_value=_snapshot(soul=AgentSoulConfig(config_note="snapshot"))) service._require_snapshot = require_snapshot @@ -258,22 +270,26 @@ def test_export_agent_app_uses_draft_or_active_snapshot(use_draft: bool) -> None assert require_snapshot.call_count == (0 if use_draft else 1) -def test_export_workflow_packages_deduplicates_shared_agent() -> None: +def test_export_workflow_packages_deduplicates_shared_agent(sqlite_session: Session) -> None: graph = {"nodes": [_agent_node("node-1"), _agent_node("node-2")], "edges": []} bindings = [ - SimpleNamespace( + WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", node_id=node_id, agent_id="agent-1", current_snapshot_id="snapshot-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, - node_job_config_dict={"workflow_prompt": node_id}, + node_job_config={"workflow_prompt": node_id}, + created_by="account-1", ) for node_id in ("node-1", "node-2") ] - session = Mock() - session.scalars.return_value.all.return_value = bindings - session.execute.return_value = [] - service = AgentDslService(session) + sqlite_session.add_all(bindings) + sqlite_session.flush() + service = AgentDslService(sqlite_session) service._require_agent = Mock(return_value=_agent()) service._require_snapshot = Mock(return_value=_snapshot()) @@ -292,12 +308,9 @@ def test_export_workflow_packages_deduplicates_shared_agent() -> None: assert service._require_agent.call_count == 2 -def test_export_workflow_packages_rejects_incomplete_binding() -> None: - session = Mock() - session.scalars.return_value.all.return_value = [] - +def test_export_workflow_packages_rejects_incomplete_binding(sqlite_session: Session) -> None: with pytest.raises(ValueError, match="no complete persisted binding"): - AgentDslService(session).export_workflow_packages( + AgentDslService(sqlite_session).export_workflow_packages( workflow=SimpleNamespace(tenant_id="tenant-1", id="workflow-1", version="draft"), graph={"nodes": [_agent_node("node-1")], "edges": []}, ) @@ -328,9 +341,10 @@ def test_graph_without_package_bindings_removes_portable_fields() -> None: assert AGENT_NODE_JOB_DSL_KEY in graph["nodes"][0]["data"] -def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - service = AgentDslService(session) +def test_import_agent_app_package_creates_config_and_unpublished_draft( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + service = AgentDslService(sqlite_session) soul = AgentSoulConfig(config_note="portable") warning = DslImportWarning(code="setup", path="agent.soul", message="setup required") service._resolve_package_soul = Mock(return_value=(soul, [warning])) @@ -361,11 +375,10 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypat assert agent.active_config_is_published is False assert app.name == "Portable Agent" assert app.description == "description" - assert session.add.call_count == 2 - assert session.flush.call_count == 2 + assert sqlite_session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == agent.id)) is not None -def test_import_workflow_packages_materializes_every_package_binding_as_inline() -> None: +def test_import_workflow_packages_materializes_every_package_binding_as_inline(sqlite_session: Session) -> None: package = make_portable_agent_package(_agent(), AgentSoulConfig()) graph = { "nodes": [ @@ -388,14 +401,22 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() } for node in graph["nodes"][:3]: node["data"][AGENT_NODE_JOB_DSL_KEY] = {"workflow_prompt": node["id"]} - old_binding = SimpleNamespace( + old_binding = WorkflowAgentNodeBinding( id="old-binding", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="old-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="old-inline-agent", + current_snapshot_id="old-snapshot", + node_job_config={}, + created_by="account-1", ) - session = Mock() - session.scalars.return_value.all.return_value = [old_binding] - service = AgentDslService(session) + sqlite_session.add(old_binding) + sqlite_session.flush() + service = AgentDslService(sqlite_session) imported_results = [ SimpleNamespace( agent=SimpleNamespace(id=f"inline-agent-{index}"), @@ -420,7 +441,7 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() account=SimpleNamespace(id="account-1"), ) - session.delete.assert_called_once_with(old_binding) + assert sqlite_session.get(WorkflowAgentNodeBinding, "old-binding") is None assert retirement_candidates == {"old-inline-agent"} assert service._create_imported_inline_agent.call_count == 3 assert [call.kwargs["node_id"] for call in service._create_imported_inline_agent.call_args_list] == [ @@ -438,8 +459,10 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in bindings) assert AGENT_NODE_JOB_DSL_KEY not in result["nodes"][0]["data"] assert json.loads(workflow.graph) == result - added_bindings = [item.args[0] for item in session.add.call_args_list] - assert all(isinstance(binding, WorkflowAgentNodeBinding) for binding in added_bindings) + added_bindings = sqlite_session.scalars( + select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == "workflow-1") + ).all() + assert len(added_bindings) == 3 assert all(binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT for binding in added_bindings) @@ -453,13 +476,13 @@ def test_import_workflow_packages_materializes_every_package_binding_as_inline() ({"binding_type": "invalid", AGENT_PACKAGE_REF_KEY: "agent_1"}, "invalid binding type"), ], ) -def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, error: str) -> None: - session = Mock() - session.scalars.return_value.all.return_value = [] +def test_import_workflow_packages_rejects_invalid_package_binding( + binding: dict, error: str, sqlite_session: Session +) -> None: package = make_portable_agent_package(_agent(), AgentSoulConfig()) with pytest.raises(ValueError, match=error): - AgentDslService(session).import_workflow_packages( + AgentDslService(sqlite_session).import_workflow_packages( workflow=SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1", version="draft"), portable_graph={"nodes": [_agent_node("node-1", binding)], "edges": []}, raw_packages={"agent_1": package.model_dump(mode="json")}, @@ -467,9 +490,8 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict, ) -def test_clone_inline_binding_copies_soul() -> None: - session = Mock() - service = AgentDslService(session) +def test_clone_inline_binding_copies_soul(unbound_session: Session) -> None: + service = AgentDslService(unbound_session) target_agent = SimpleNamespace(id="target-agent") target_snapshot = SimpleNamespace(id="target-snapshot") service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot)) @@ -504,7 +526,9 @@ def test_clone_inline_binding_copies_soul() -> None: assert create_kwargs["source"] == AgentSource.WORKFLOW -def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: +def test_extract_package_dependencies_covers_model_tools_and_knowledge( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: model_dependency = Mock(side_effect=lambda provider: f"model:{provider}") tool_dependency = Mock(side_effect=lambda provider: f"tool:{provider}") monkeypatch.setattr( @@ -551,7 +575,7 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat } ) - dependencies = AgentDslService(Mock()).extract_package_dependencies( + dependencies = AgentDslService(unbound_session).extract_package_dependencies( {"agent_1": make_portable_agent_package(_agent(), soul)} ) @@ -564,8 +588,8 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat ] -def test_create_imported_inline_agent_uses_import_provenance() -> None: - service = AgentDslService(Mock()) +def test_create_imported_inline_agent_uses_import_provenance(unbound_session: Session) -> None: + service = AgentDslService(unbound_session) soul = AgentSoulConfig(config_note="inline") warning = DslImportWarning(code="setup", path="agent", message="setup") service._resolve_package_soul = Mock(return_value=(soul, [warning])) @@ -587,9 +611,10 @@ def test_create_imported_inline_agent_uses_import_provenance() -> None: ) -def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - service = AgentDslService(session) +def test_create_workflow_only_agent_sets_backing_app_and_snapshot( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + service = AgentDslService(sqlite_session) roster_service = Mock() roster_service.create_hidden_backing_app_for_workflow_agent.return_value = SimpleNamespace(id="backing-app") monkeypatch.setattr("services.agent.dsl_service.AgentRosterService", Mock(return_value=roster_service)) @@ -613,11 +638,12 @@ def test_create_workflow_only_agent_sets_backing_app_and_snapshot(monkeypatch: p assert agent.active_config_snapshot_id == "snapshot-1" assert agent.active_config_has_model is True assert agent.active_config_is_published is True - session.add.assert_called_once_with(agent) - assert session.flush.call_count == 2 + assert sqlite_session.get(Agent, agent.id) is agent -def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: +def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: soul = AgentSoulConfig.model_validate( { "config_skills": [{"name": "skill", "file_kind": "tool_file", "file_id": "skill-file"}], @@ -638,18 +664,17 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon }, } ) - session = Mock() get_dataset_rows = Mock(return_value={"existing": SimpleNamespace(id="existing")}) monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", get_dataset_rows) - resolved, warnings = AgentDslService(session)._resolve_package_soul( + resolved, warnings = AgentDslService(unbound_session)._resolve_package_soul( tenant_id="tenant-1", package=make_portable_agent_package(_agent(), soul), package_path="agent_packages.agent_1", ) get_dataset_rows.assert_called_once_with( - session=session, + session=unbound_session, tenant_id="tenant-1", dataset_ids=["existing", "missing"], ) @@ -683,14 +708,18 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(mon } -def test_create_snapshot_increments_version_and_records_revision() -> None: - session = Mock() - session.scalar.return_value = 2 - service = AgentDslService(session) +def test_create_snapshot_increments_version_and_records_revision(sqlite_session: Session) -> None: + agent = _agent() + first = _snapshot(snapshot_id="snapshot-1") + second = _snapshot(snapshot_id="snapshot-2") + second.version = 2 + sqlite_session.add_all([agent, first, second]) + sqlite_session.flush() + service = AgentDslService(sqlite_session) snapshot = service._create_snapshot( tenant_id="tenant-1", - agent=_agent(), + agent=agent, account_id="account-1", soul=AgentSoulConfig(config_note="version 3"), operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, @@ -698,28 +727,32 @@ def test_create_snapshot_increments_version_and_records_revision() -> None: assert snapshot.version == 3 assert snapshot.home_snapshot_id is None - assert isinstance(session.add.call_args_list[0].args[0], AgentConfigSnapshot) - revision = session.add.call_args_list[1].args[0] - assert isinstance(revision, AgentConfigRevision) + revision = sqlite_session.scalar( + select(AgentConfigRevision).where(AgentConfigRevision.current_snapshot_id == snapshot.id) + ) + assert revision is not None assert revision.operation == AgentConfigRevisionOperation.IMPORT_PACKAGE - assert session.flush.call_count == 2 -def test_unique_roster_name_uses_first_available_suffix() -> None: - session = Mock() - session.scalars.return_value.all.return_value = ["Agent", "Agent import"] +def test_unique_roster_name_uses_first_available_suffix(sqlite_session: Session) -> None: + for index, name in enumerate(("Agent", "Agent import"), start=1): + agent = _agent() + agent.id = f"agent-{index}" + agent.name = name + sqlite_session.add(agent) + sqlite_session.flush() - result = AgentDslService(session)._unique_roster_name(tenant_id="tenant-1", requested="Agent") + result = AgentDslService(sqlite_session)._unique_roster_name(tenant_id="tenant-1", requested="Agent") assert result == "Agent import 2" -def test_require_helpers_and_graph_detection() -> None: - session = Mock() - service = AgentDslService(session) +def test_require_helpers_and_graph_detection(sqlite_session: Session) -> None: + service = AgentDslService(sqlite_session) agent = _agent() snapshot = _snapshot() - session.scalar.side_effect = [agent, None, snapshot, None] + sqlite_session.add_all([agent, snapshot]) + sqlite_session.flush() assert service._require_agent(tenant_id="tenant-1", agent_id="agent-1") is agent with pytest.raises(ValueError, match="source Agent"): @@ -733,17 +766,4 @@ def test_require_helpers_and_graph_detection() -> None: assert AgentDslService._agent_icon_type(AgentIconType.EMOJI.value) == AgentIconType.EMOJI assert AgentDslService._agent_icon_type(None) is None assert is_agent_v2_graph({"nodes": [_agent_node("agent")]}) is True - assert is_agent_v2_graph({"nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2"}}]}) is False assert is_agent_v2_graph({"nodes": ["invalid", {"data": {"type": "start"}}]}) is False - - -def test_export_workflow_packages_ignores_historical_agent_version_two() -> None: - session = Mock() - service = AgentDslService(session) - graph = {"nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2"}}]} - - portable_graph, packages = service.export_workflow_packages(workflow=Mock(), graph=graph) - - assert portable_graph == graph - assert packages == {} - session.scalars.assert_not_called() diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py index 8bcf67dbf93..32a6e5a54d8 100644 --- a/api/tests/unit_tests/services/agent/test_agent_observability_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -1,7 +1,6 @@ import json from datetime import UTC, datetime from decimal import Decimal -from types import SimpleNamespace import pytest from sqlalchemy import Select, select @@ -30,6 +29,7 @@ from models.workflow import ( ) from services.agent import observability_service as observability_service_module from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService +from tests.unit_tests.config_override import apply_config_overrides def _app(*, app_id: str = "app-1", name: str = "Iris", mode: AppMode = AppMode.AGENT_CHAT) -> App: @@ -291,7 +291,7 @@ def test_statistics_workflow_chat_context_only_uses_chat_runs() -> None: def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="postgresql")) + apply_config_overrides(monkeypatch, DB_TYPE="postgresql") postgres_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql( ("agent_log", "agent_backend", "usage", "total_tokens"), "BIGINT" @@ -300,7 +300,7 @@ def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch assert "CAST(wne.execution_metadata AS JSONB)" in postgres_sql assert "#>> '{agent_log,agent_backend,usage,total_tokens}'" in postgres_sql - monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="mysql")) + apply_config_overrides(monkeypatch, DB_TYPE="mysql") mysql_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql(("total_tokens",), "BIGINT") @@ -315,7 +315,7 @@ def test_workflow_statistics_include_run_without_message( sqlite_session.add_all([workflow_app, _workflow_run(), _node_execution(), _workflow_binding()]) sqlite_session.commit() - monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="mysql")) + apply_config_overrides(monkeypatch, DB_TYPE="mysql") monkeypatch.setattr(observability_service_module, "convert_datetime_to_date", lambda field: f"DATE({field})") monkeypatch.setattr( AgentObservabilityService, diff --git a/api/tests/unit_tests/services/agent/test_home_snapshot_service.py b/api/tests/unit_tests/services/agent/test_home_snapshot_service.py index c66a26aeeb7..3b2a638f692 100644 --- a/api/tests/unit_tests/services/agent/test_home_snapshot_service.py +++ b/api/tests/unit_tests/services/agent/test_home_snapshot_service.py @@ -6,13 +6,15 @@ import pytest from dify_agent.client import DifyAgentHTTPError, DifyAgentNotFoundError, DifyAgentTimeoutError from sqlalchemy.orm import Session -from configs import dify_config from models.agent import ( Agent, AgentConfigDraft, AgentConfigDraftType, AgentConfigSnapshot, AgentHomeSnapshot, + AgentScope, + AgentSource, + AgentStatus, AgentWorkingResourceStatus, ) from models.agent_config_entities import AgentSoulConfig @@ -23,6 +25,7 @@ from services.agent.errors import ( ) from services.agent.home_snapshot_service import AgentHomeSnapshotService, validate_home_snapshot_binding from services.agent.workspace_service import AgentWorkspaceService +from tests.unit_tests.config_override import apply_config_overrides def _build_draft(*, home_snapshot_id: str | None = "home-old") -> AgentConfigDraft: @@ -46,23 +49,38 @@ def _client(*, snapshot_ref: str = "snapshot-ref-1") -> MagicMock: def test_home_snapshot_client_outlasts_the_gateway_snapshot_budget(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent.example") + apply_config_overrides(monkeypatch, AGENT_BACKEND_BASE_URL="http://agent.example") client = AgentHomeSnapshotService._client() assert client._timeout == 45.0 -def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_lookup() -> None: - session = MagicMock() +def _persist_agent(session: Session, *, app_id: str, backing_app_id: str | None) -> Agent: + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Snapshot Agent", + description="", + role="", + scope=AgentScope.ROSTER if backing_app_id is None else AgentScope.WORKFLOW_ONLY, + source=AgentSource.AGENT_APP if backing_app_id is None else AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + app_id=app_id, + backing_app_id=backing_app_id, + ) + session.add(agent) + session.commit() + return agent + + +def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_lookup(unbound_session: Session) -> None: validate_home_snapshot_binding( - session=session, + session=unbound_session, agent=Agent(id="agent-1"), home_snapshot_id=None, ) - session.scalar.assert_not_called() - @pytest.mark.parametrize( ("app_id", "backing_app_id", "expected_runtime_app_id"), @@ -73,12 +91,12 @@ def test_validate_home_snapshot_binding_accepts_default_home_without_ledger_look ) def test_build_apply_checkpoints_exact_active_binding( monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, app_id: str, backing_app_id: str | None, expected_runtime_app_id: str, ) -> None: - session = MagicMock() - session.scalar.return_value = SimpleNamespace(app_id=app_id, backing_app_id=backing_app_id) + _persist_agent(sqlite_session, app_id=app_id, backing_app_id=backing_app_id) binding = SimpleNamespace( backend_binding_ref="binding-ref-1", agent_id="agent-1", @@ -94,7 +112,7 @@ def test_build_apply_checkpoints_exact_active_binding( monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation) snapshot = AgentHomeSnapshotService.create_for_build_apply( - session=session, + session=sqlite_session, build_draft=_build_draft(), ) @@ -106,9 +124,8 @@ def test_build_apply_checkpoints_exact_active_binding( assert validate_generation.call_args.kwargs["base_home_snapshot_id"] == "home-old" -def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.MonkeyPatch) -> None: - session = MagicMock() - session.scalar.return_value = SimpleNamespace(app_id="app-1", backing_app_id=None) +def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + _persist_agent(sqlite_session, app_id="app-1", backing_app_id=None) binding = SimpleNamespace( backend_binding_ref="binding-ref-1", agent_id="agent-1", @@ -123,7 +140,7 @@ def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.Monkey monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation) snapshot = AgentHomeSnapshotService.create_for_build_apply( - session=session, + session=sqlite_session, build_draft=_build_draft(home_snapshot_id=None), ) @@ -131,26 +148,26 @@ def test_build_apply_forwards_default_home_generation(monkeypatch: pytest.Monkey assert validate_generation.call_args.kwargs["base_home_snapshot_id"] is None -def test_build_apply_fails_fast_without_source_binding() -> None: - session = MagicMock() +def test_build_apply_fails_fast_without_source_binding(unbound_session: Session) -> None: build_draft = _build_draft() build_draft.agent_workspace_binding_id = None with pytest.raises(AgentBuildSandboxNotFoundError): AgentHomeSnapshotService.create_for_build_apply( - session=session, + session=unbound_session, build_draft=build_draft, ) -def test_home_snapshot_collection_database_failure_propagates(monkeypatch: pytest.MonkeyPatch) -> None: - context = MagicMock() - session = context.__enter__.return_value +def test_home_snapshot_collection_database_failure_propagates( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: error = RuntimeError("database unavailable") - session.scalar.side_effect = error + scalar = MagicMock(side_effect=error) + monkeypatch.setattr(sqlite_session, "scalar", scalar) monkeypatch.setattr( "services.agent.home_snapshot_service.session_factory.create_session", - lambda: context, + lambda: nullcontext(sqlite_session), ) with pytest.raises(RuntimeError) as exc_info: @@ -159,6 +176,7 @@ def test_home_snapshot_collection_database_failure_propagates(monkeypatch: pytes home_snapshot_id="home-1", ) + scalar.assert_called_once() assert exc_info.value is error diff --git a/api/tests/unit_tests/services/agent/test_skill_package_service.py b/api/tests/unit_tests/services/agent/test_skill_package_service.py index f634cfdda45..fa7cb7a48eb 100644 --- a/api/tests/unit_tests/services/agent/test_skill_package_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_package_service.py @@ -221,7 +221,9 @@ def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypat def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(skill_package_service_module.dify_config, "UPLOAD_SKILL_FILE_SIZE_LIMIT", 1) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=1) with pytest.raises(SkillPackageError) as exc_info: SkillPackageService().validate_and_normalize(content=b"x" * (1024 * 1024 + 1), filename="skill.zip") diff --git a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py index 74031c57844..d50f3589b68 100644 --- a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py +++ b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py @@ -7,15 +7,19 @@ from sqlalchemy.orm import Session from models.agent import ( Agent, + AgentConfigSnapshot, AgentScope, + AgentSource, + AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) +from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus from models.model import App, AppMode from models.workflow import Workflow, WorkflowType from services.agent.dsl_service import AgentDslService -from services.agent.workflow_publish_service import WorkflowAgentPublishService, _InlineAgentOwnershipError +from services.agent.workflow_publish_service import WorkflowAgentPublishService def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSION_DRAFT) -> Workflow: @@ -33,39 +37,66 @@ def _workflow(*, workflow_id: str = "workflow-1", version: str = Workflow.VERSIO ) -def test_inline_binding_from_another_node_is_cloned(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - draft_workflow = _workflow() - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_inline_agent_graph_binding", - Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")), +def _inline_agent( + *, + agent_id: str, + workflow_id: str, + node_id: str, + tenant_id: str = "tenant-1", +) -> Agent: + return Agent( + id=agent_id, + tenant_id=tenant_id, + name=f"Inline {agent_id}", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + app_id="app-1", + workflow_id=workflow_id, + workflow_node_id=node_id, ) - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_existing_inline_binding_agent", - Mock(return_value=None), + + +def _snapshot(*, snapshot_id: str, agent_id: str, version: int = 1) -> AgentConfigSnapshot: + return AgentConfigSnapshot( + id=snapshot_id, + tenant_id="tenant-1", + agent_id=agent_id, + version=version, + config_snapshot=AgentSoulConfig(), ) - clone = Mock(return_value=(SimpleNamespace(id="target-agent"), "target-snapshot")) - monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + + +def test_inline_binding_from_another_node_is_cloned(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + source_agent = _inline_agent(agent_id="source-agent", workflow_id="workflow-1", node_id="source-node") + source_snapshot = _snapshot(snapshot_id="source-snapshot", agent_id=source_agent.id) + sqlite_session.add_all([source_agent, source_snapshot]) + sqlite_session.commit() + target_agent = _inline_agent(agent_id="target-agent", workflow_id="workflow-1", node_id="pasted-node") + target_snapshot = _snapshot(snapshot_id="target-snapshot", agent_id=target_agent.id) + clone = Mock(return_value=(target_agent, target_snapshot)) + monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) WorkflowAgentPublishService._sync_agent_binding_for_node( - session=session, - draft_workflow=draft_workflow, + session=sqlite_session, + draft_workflow=_workflow(), node_id="pasted-node", node_data={"agent_task": "Summarize the input"}, node_binding={ "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, - "agent_id": "source-agent", - "current_snapshot_id": "source-snapshot", + "agent_id": source_agent.id, + "current_snapshot_id": source_snapshot.id, }, existing_binding=None, account_id="account-1", ) + sqlite_session.flush() clone.assert_called_once() - binding = session.add.call_args.args[0] - assert isinstance(binding, WorkflowAgentNodeBinding) + binding = sqlite_session.scalar( + select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "pasted-node") + ) + assert binding is not None assert binding.agent_id == "target-agent" assert binding.current_snapshot_id == "target-snapshot" assert binding.node_job_config.workflow_prompt == "Summarize the input" @@ -103,8 +134,9 @@ def test_draft_sync_resolves_roster_agents() -> None: assert {call.args[0].agent_id for call in session.add.call_args_list} == {"agent-a", "agent-b"} -def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> None: +def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent(sqlite_session: Session) -> None: existing_inline = WorkflowAgentNodeBinding( + id="existing-inline", tenant_id="tenant-1", app_id="app-1", workflow_id="draft-workflow", @@ -117,6 +149,7 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N created_by="account-1", ) existing_roster = WorkflowAgentNodeBinding( + id="existing-roster", tenant_id="tenant-1", app_id="app-1", workflow_id="draft-workflow", @@ -129,6 +162,7 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N created_by="account-1", ) source = WorkflowAgentNodeBinding( + id="source-roster", tenant_id="tenant-1", app_id="app-1", workflow_id="published-workflow", @@ -140,30 +174,34 @@ def test_restore_replaces_bindings_and_returns_only_replaced_inline_agent() -> N node_job_config={"workflow_prompt": "Use the roster agent"}, created_by="account-1", ) - session = Mock() - session.scalars.side_effect = [ - SimpleNamespace(all=lambda: [existing_inline, existing_roster]), - SimpleNamespace(all=lambda: [source]), - ] - session.scalar.return_value = SimpleNamespace( + roster_agent = Agent( id="roster-agent", + tenant_id="tenant-1", + name="Roster Agent", scope=AgentScope.ROSTER, + source=AgentSource.ROSTER, + status=AgentStatus.ACTIVE, + app_id="roster-app", active_config_snapshot_id="published-snapshot", ) + sqlite_session.add_all([existing_inline, existing_roster, source, roster_agent]) + sqlite_session.commit() retirement_candidates = WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( - session=session, + session=sqlite_session, source_workflow=_workflow(workflow_id="published-workflow", version="2026-07-13 00:00:00"), draft_workflow=_workflow(workflow_id="draft-workflow"), account_id="account-2", ) - assert {item.args[0].agent_id for item in session.delete.call_args_list} == { - "old-inline-agent", - "old-roster-agent", - } - restored = session.add.call_args.args[0] - assert isinstance(restored, WorkflowAgentNodeBinding) - assert restored.workflow_id == "draft-workflow" + assert sqlite_session.get(WorkflowAgentNodeBinding, existing_inline.id) is None + assert sqlite_session.get(WorkflowAgentNodeBinding, existing_roster.id) is None + restored = sqlite_session.scalar( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.workflow_id == "draft-workflow", + WorkflowAgentNodeBinding.node_id == "agent-node", + ) + ) + assert restored is not None assert restored.workflow_version == Workflow.VERSION_DRAFT assert restored.agent_id == "roster-agent" assert restored.current_snapshot_id == "published-snapshot" @@ -284,6 +322,7 @@ def test_publish_binding_copy_keeps_previous_published_owner( draft_workflow=draft_workflow, published_workflow=published_workflow, ) + sqlite_session.flush() assert result is True assert sqlite_session.get(WorkflowAgentNodeBinding, previous_inline_binding.id) is previous_inline_binding @@ -299,55 +338,50 @@ def test_publish_binding_copy_keeps_previous_published_owner( assert copied.current_snapshot_id == "draft-inline-snapshot" -def test_inline_binding_reuses_existing_node_owned_agent(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - draft_workflow = _workflow() +def test_inline_binding_reuses_existing_node_owned_agent(sqlite_session: Session) -> None: + existing_agent = _inline_agent(agent_id="existing-agent", workflow_id="workflow-1", node_id="pasted-node") + existing_snapshot = _snapshot(snapshot_id="existing-snapshot", agent_id=existing_agent.id) existing_binding = WorkflowAgentNodeBinding( + id="existing-binding", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="pasted-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, - agent_id="existing-agent", - current_snapshot_id="existing-snapshot", + agent_id=existing_agent.id, + current_snapshot_id=existing_snapshot.id, node_job_config={}, created_by="account-1", ) - existing_agent = SimpleNamespace(id="existing-agent") - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_inline_agent_graph_binding", - Mock(side_effect=_InlineAgentOwnershipError("source belongs to another node")), - ) - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_existing_inline_binding_agent", - Mock(return_value=existing_agent), - ) - clone = Mock() - monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + sqlite_session.add_all([existing_agent, existing_snapshot, existing_binding]) + sqlite_session.commit() WorkflowAgentPublishService._sync_agent_binding_for_node( - session=session, - draft_workflow=draft_workflow, + session=sqlite_session, + draft_workflow=_workflow(), node_id="pasted-node", node_data={"agent_task": "Summarize"}, node_binding={ "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, - "agent_id": "source-agent", - "current_snapshot_id": "source-snapshot", + "agent_id": "unavailable-source-agent", + "current_snapshot_id": "unavailable-source-snapshot", }, existing_binding=existing_binding, account_id="account-1", ) + sqlite_session.flush() - assert existing_binding.agent_id == "existing-agent" - assert existing_binding.current_snapshot_id == "existing-snapshot" - clone.assert_not_called() + stored = sqlite_session.get(WorkflowAgentNodeBinding, existing_binding.id) + assert stored is not None + assert stored.agent_id == "existing-agent" + assert stored.current_snapshot_id == "existing-snapshot" + assert stored.node_job_config.workflow_prompt == "Summarize" -def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monkeypatch: pytest.MonkeyPatch) -> None: +def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", @@ -360,13 +394,13 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke node_job_config={}, created_by="account-1", ) - resolved = SimpleNamespace(id="agent-1") + resolved = _inline_agent(agent_id="agent-1", workflow_id="workflow-1", node_id="node-1") resolver = Mock(return_value=resolved) monkeypatch.setattr(WorkflowAgentPublishService, "_resolve_inline_agent_graph_binding", resolver) assert ( WorkflowAgentPublishService._resolve_existing_inline_binding_agent( - session=Mock(), + session=unbound_session, draft_workflow=_workflow(), node_id="node-1", existing_binding=binding, @@ -377,7 +411,7 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke resolver.side_effect = ValueError("stale") assert ( WorkflowAgentPublishService._resolve_existing_inline_binding_agent( - session=Mock(), + session=unbound_session, draft_workflow=_workflow(), node_id="node-1", existing_binding=binding, @@ -386,30 +420,42 @@ def test_resolve_existing_inline_binding_agent_returns_valid_agent_or_none(monke ) -def test_resolve_roster_binding_rejects_unpublished_agent() -> None: - session = Mock() - session.scalar.return_value = None +def test_resolve_roster_binding_rejects_unpublished_agent(sqlite_session: Session) -> None: + sqlite_session.add( + Agent( + id="decoy-agent", + tenant_id="tenant-1", + name="Decoy", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="decoy-app", + ) + ) + sqlite_session.commit() with pytest.raises(ValueError, match="unavailable or unpublished roster agent"): WorkflowAgentPublishService._resolve_roster_agent_graph_binding( - session=session, + session=sqlite_session, draft_workflow=_workflow(), node_id="agent-node", agent_id="agent-1", ) -def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.MonkeyPatch) -> None: - session = Mock() - source_agent = SimpleNamespace(id="source-agent") - source_snapshot = SimpleNamespace(id="source-snapshot") - session.scalar.side_effect = [source_agent, source_snapshot] - target_agent = SimpleNamespace(id="target-agent") - target_snapshot = SimpleNamespace(id="target-snapshot") +def test_clone_inline_graph_binding_for_node_clones_source( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + source_agent = _inline_agent(agent_id="source-agent", workflow_id="source-workflow", node_id="source-node") + source_snapshot = _snapshot(snapshot_id="source-snapshot", agent_id=source_agent.id) + sqlite_session.add_all([source_agent, source_snapshot]) + sqlite_session.commit() + target_agent = _inline_agent(agent_id="target-agent", workflow_id="workflow-1", node_id="target-node") + target_snapshot = _snapshot(snapshot_id="target-snapshot", agent_id=target_agent.id) clone = Mock(return_value=(target_agent, target_snapshot)) monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node( - session=session, + session=sqlite_session, draft_workflow=_workflow(), node_id="target-node", source_agent_id="source-agent", @@ -427,14 +473,17 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M ) -@pytest.mark.parametrize("scalar_results", [[None], [SimpleNamespace(id="source-agent"), None]]) -def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_results: list[object | None]) -> None: - session = Mock() - session.scalar.side_effect = scalar_results +@pytest.mark.parametrize("persist_source_agent", [False, True]) +def test_clone_inline_graph_binding_for_node_rejects_missing_source( + sqlite_session: Session, persist_source_agent: bool +) -> None: + if persist_source_agent: + sqlite_session.add(_inline_agent(agent_id="source-agent", workflow_id="source-workflow", node_id="source-node")) + sqlite_session.commit() with pytest.raises(ValueError, match="unavailable inline agent|missing inline agent config snapshot"): WorkflowAgentPublishService._clone_inline_graph_binding_for_node( - session=session, + session=sqlite_session, draft_workflow=_workflow(), node_id="target-node", source_agent_id="source-agent", @@ -443,37 +492,45 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul ) -def test_restore_clones_inline_binding_owned_by_published_workflow(monkeypatch: pytest.MonkeyPatch) -> None: +def test_restore_clones_inline_binding_owned_by_published_workflow( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + source_agent = _inline_agent(agent_id="published-agent", workflow_id="published-workflow", node_id="agent-node") + source_snapshot = _snapshot(snapshot_id="published-snapshot", agent_id=source_agent.id) source = WorkflowAgentNodeBinding( + id="published-binding", tenant_id="tenant-1", app_id="app-1", workflow_id="published-workflow", workflow_version="published", node_id="agent-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, - agent_id="published-agent", - current_snapshot_id="published-snapshot", + agent_id=source_agent.id, + current_snapshot_id=source_snapshot.id, node_job_config={"workflow_prompt": "work"}, created_by="account-1", ) - session = Mock() - session.scalars.side_effect = [SimpleNamespace(all=lambda: []), SimpleNamespace(all=lambda: [source])] - monkeypatch.setattr( - WorkflowAgentPublishService, - "_resolve_inline_agent_graph_binding", - Mock(side_effect=ValueError("owned by published workflow")), - ) - clone = Mock(return_value=(SimpleNamespace(id="draft-agent"), "draft-snapshot")) - monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) + sqlite_session.add_all([source_agent, source_snapshot, source]) + sqlite_session.commit() + target_agent = _inline_agent(agent_id="draft-agent", workflow_id="draft-workflow", node_id="agent-node") + target_snapshot = _snapshot(snapshot_id="draft-snapshot", agent_id=target_agent.id) + clone = Mock(return_value=(target_agent, target_snapshot)) + monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone) WorkflowAgentPublishService.restore_agent_node_bindings_to_draft( - session=session, + session=sqlite_session, source_workflow=_workflow(workflow_id="published-workflow", version="published"), draft_workflow=_workflow(workflow_id="draft-workflow"), account_id="account-2", ) clone.assert_called_once() - restored = session.add.call_args.args[0] + restored = sqlite_session.scalar( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.workflow_id == "draft-workflow", + WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT, + ) + ) + assert restored is not None assert restored.agent_id == "draft-agent" assert restored.current_snapshot_id == "draft-snapshot" diff --git a/api/tests/unit_tests/services/agent/test_workspace_service.py b/api/tests/unit_tests/services/agent/test_workspace_service.py index e8ce1e1f657..2a239c6e726 100644 --- a/api/tests/unit_tests/services/agent/test_workspace_service.py +++ b/api/tests/unit_tests/services/agent/test_workspace_service.py @@ -7,7 +7,6 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import Session -from configs import dify_config from models.agent import ( AgentConfigVersionKind, AgentHomeSnapshot, @@ -22,6 +21,7 @@ from services.agent.workspace_service import ( AgentWorkspaceService, WorkspaceOwnerScope, ) +from tests.unit_tests.config_override import apply_config_overrides def _scope() -> WorkspaceOwnerScope: @@ -94,19 +94,17 @@ def _binding( def test_workspace_client_honors_the_configured_snapshot_timeout(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent.example") - monkeypatch.setattr(dify_config, "AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS", 123.5) + apply_config_overrides( + monkeypatch, + AGENT_BACKEND_BASE_URL="http://agent.example", + AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS=123.5, + ) client = AgentWorkspaceService._client() assert client._timeout == 123.5 -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_binding_success_persists_new_workspace_and_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -145,11 +143,6 @@ def test_create_binding_success_persists_new_workspace_and_binding( assert request.home_snapshot_ref == "home-ref" -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_binding_without_home_snapshot_uses_backend_default( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -173,11 +166,6 @@ def test_create_binding_without_home_snapshot_uses_backend_default( assert request.home_snapshot_ref is None -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_binding_rejects_missing_explicit_home_snapshot_before_backend_call( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -197,11 +185,6 @@ def test_create_binding_rejects_missing_explicit_home_snapshot_before_backend_ca client.create_execution_binding_sync.assert_not_called() -@pytest.mark.parametrize( - "sqlite_session", - [(AgentHomeSnapshot, AgentWorkspace, AgentWorkspaceBinding)], - indirect=True, -) def test_create_second_binding_reuses_existing_workspace( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -240,7 +223,6 @@ def test_create_second_binding_reuses_existing_workspace( assert request.workspace_id == workspace.id -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_get_active_binding_resolves_exact_participant(sqlite_session: Session) -> None: conversation_workspace = _workspace(workspace_id="workspace-conversation") build_workspace = _workspace( @@ -271,7 +253,6 @@ def test_get_active_binding_resolves_exact_participant(sqlite_session: Session) assert resolved.id == conversation_binding.id -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_get_active_binding_rejects_wrong_owner(sqlite_session: Session) -> None: build_workspace = _workspace( workspace_id="workspace-build", @@ -296,7 +277,6 @@ def test_get_active_binding_rejects_wrong_owner(sqlite_session: Session) -> None assert resolved is None -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_retire_non_final_binding_keeps_workspace_active(sqlite_session: Session) -> None: binding = _binding() other_binding = _binding(binding_id="binding-2", agent_id="agent-2") @@ -317,7 +297,6 @@ def test_retire_non_final_binding_keeps_workspace_active(sqlite_session: Session assert other_binding.status is AgentWorkingResourceStatus.ACTIVE -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_retire_final_binding_retires_workspace(sqlite_session: Session) -> None: binding = _binding() workspace = _workspace() @@ -332,15 +311,14 @@ def test_retire_final_binding_retires_workspace(sqlite_session: Session) -> None assert workspace.retired_at == binding.retired_at -def test_retire_workspace_retires_all_active_bindings() -> None: +def test_retire_workspace_retires_all_active_bindings(sqlite_session: Session) -> None: workspace = _workspace() bindings = [_binding(), _binding(binding_id="binding-2", agent_id="agent-2")] - session = MagicMock() - session.scalar.return_value = workspace - session.scalars.return_value.all.return_value = bindings + sqlite_session.add_all([workspace, *bindings]) + sqlite_session.flush() retired_id = AgentWorkspaceService.retire_workspace( - session=session, + session=sqlite_session, tenant_id="tenant-1", workspace_id=workspace.id, ) @@ -351,7 +329,6 @@ def test_retire_workspace_retires_all_active_bindings() -> None: assert all(binding.retired_at == workspace.retired_at for binding in bindings) -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_retire_all_for_app_retires_only_active_workspaces_for_that_app(sqlite_session: Session) -> None: active = _workspace(workspace_id="workspace-active", owner_id="conversation-active") already_retired = _workspace( @@ -387,7 +364,6 @@ def test_retire_all_for_app_retires_only_active_workspaces_for_that_app(sqlite_s assert other_binding.status is AgentWorkingResourceStatus.ACTIVE -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_collect_binding_without_retired_workspace_destroys_binding_only( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: @@ -411,7 +387,6 @@ def test_collect_binding_without_retired_workspace_destroys_binding_only( assert sqlite_session.get(AgentWorkspace, workspace.id) is not None -@pytest.mark.parametrize("sqlite_session", [(AgentWorkspace, AgentWorkspaceBinding)], indirect=True) def test_collect_workspace_destroys_workspace_then_remaining_bindings( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: diff --git a/api/tests/unit_tests/services/controller_api.py b/api/tests/unit_tests/services/controller_api.py index 4dd0019cfc0..3ef2a15c3ca 100644 --- a/api/tests/unit_tests/services/controller_api.py +++ b/api/tests/unit_tests/services/controller_api.py @@ -82,7 +82,7 @@ This test suite follows a comprehensive testing strategy that covers: ================================================================================ """ -from collections.abc import Iterator +from collections.abc import Callable, Iterator from types import SimpleNamespace from unittest.mock import Mock, patch from uuid import uuid4 @@ -813,7 +813,8 @@ class TestExternalDatasetApi: ) @pytest.fixture - def mock_current_account_context(self, app: Flask) -> Iterator[Mock]: + def mock_current_account_context(self, app: Flask, config_overrides: Callable[..., None]) -> Iterator[Mock]: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) """Provide the wrapper auth context required by HTTP-client controller tests.""" mock_user = Account( name="Test User", @@ -830,7 +831,6 @@ class TestExternalDatasetApi: with ( patch("controllers.console.wraps.current_account_with_tenant") as mock_get_user, - patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("libs.login.check_csrf_token", return_value=None), ): mock_tenant_id = "tenant-123" diff --git a/api/tests/unit_tests/services/data_migration/test_import_service.py b/api/tests/unit_tests/services/data_migration/test_import_service.py index 80e155c1dae..512d2f10e66 100644 --- a/api/tests/unit_tests/services/data_migration/test_import_service.py +++ b/api/tests/unit_tests/services/data_migration/test_import_service.py @@ -28,6 +28,7 @@ from services.data_migration.entities import ( ) from services.data_migration.import_service import ImportRequest, ImportTargetResolver, MigrationImportService from services.entities.dsl_entities import ImportStatus +from tests.unit_tests.config_override import apply_config_overrides @dataclass(frozen=True) @@ -347,7 +348,7 @@ def test_workflow_app_import_closes_read_transaction_before_dsl_overwrite( return Import(id="import-id", status=ImportStatus.COMPLETED, app_id="imported-app-id") monkeypatch.setattr(import_service, "AppDslService", StubAppDslService) - monkeypatch.setattr(import_service.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) existing_app = _persist_app(database.session, app_id="11111111-1111-4111-8111-111111111111") database.session.begin() diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index 796dab2ea9b..7e7d1ee2cf6 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -1093,7 +1093,7 @@ class TestMemberRoles: } assert persisted_joins == { "acct-2": svc.TenantAccountRole.OWNER, - "acct-owner": svc.TenantAccountRole.ADMIN, + "acct-owner": svc.TenantAccountRole.NORMAL, } assert out.roles[0].id == "owner" @@ -1264,16 +1264,12 @@ class TestListOption: class TestLegacyAgentManageKey: def test_legacy_agent_manage_key_membership(self): - # Mirrors the builtin roles in the rbac service, which grant agent.manage - # to owner/admin/editor only. + # Preserve Agent access for every legacy role while external RBAC is disabled. for keys in ( svc._LEGACY_WORKSPACE_OWNER_KEYS, svc._LEGACY_WORKSPACE_ADMIN_KEYS, svc._LEGACY_WORKSPACE_EDITOR_KEYS, - ): - assert "agent.manage" in keys - for keys in ( svc._LEGACY_WORKSPACE_NORMAL_KEYS, svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, ): - assert "agent.manage" not in keys + assert "agent.manage" in keys diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py index 273f89b8db3..27eff6e2aeb 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from services.rag_pipeline.pipeline_template.database.database_retrieval import DatabasePipelineTemplateRetrieval from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType from services.rag_pipeline.pipeline_template.remote.remote_retrieval import RemotePipelineTemplateRetrieval +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @@ -54,12 +55,8 @@ def test_get_pipeline_template_detail_fallbacks_to_database_on_error( assert not sqlite_session.in_transaction() -def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture) -> None: - mocker.patch( - "services.rag_pipeline.pipeline_template.remote.remote_retrieval" - ".dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN", - "https://example.com", - ) +def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN="https://example.com") success_response = mocker.Mock(status_code=200) success_response.json.return_value = {"pipeline_templates": [{"id": "remote-1"}]} @@ -80,12 +77,10 @@ def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture) -> N assert http_get_mock.call_count == 2 -def test_fetch_pipeline_template_detail_from_dify_official(mocker: MockerFixture) -> None: - mocker.patch( - "services.rag_pipeline.pipeline_template.remote.remote_retrieval" - ".dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN", - "https://example.com", - ) +def test_fetch_pipeline_template_detail_from_dify_official( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + apply_config_overrides(monkeypatch, HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN="https://example.com") success_response = mocker.Mock(status_code=200) success_response.json.return_value = {"id": "remote-1", "name": "Remote Template"} diff --git a/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py b/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py index 27188816af6..fabc093c9b0 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import pytest from pytest_mock import MockerFixture from sqlalchemy.orm import Session @@ -76,9 +78,10 @@ def _make_document( ) -def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker: MockerFixture) -> None: - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_DEFAULT_ACTIVE_REQUESTS", 5) - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_MAX_ACTIVE_REQUESTS", 3) +def test_get_max_active_requests_uses_smallest_non_zero_limit( + config_overrides: Callable[..., None], +) -> None: + config_overrides(APP_DEFAULT_ACTIVE_REQUESTS=5, APP_MAX_ACTIVE_REQUESTS=3) app_model = _make_app(max_active_requests=10) @@ -87,9 +90,10 @@ def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker: MockerFixt assert result == 3 -def test_get_max_active_requests_returns_zero_when_all_unlimited(mocker: MockerFixture) -> None: - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_DEFAULT_ACTIVE_REQUESTS", 0) - mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_MAX_ACTIVE_REQUESTS", 0) +def test_get_max_active_requests_returns_zero_when_all_unlimited( + config_overrides: Callable[..., None], +) -> None: + config_overrides(APP_DEFAULT_ACTIVE_REQUESTS=0, APP_MAX_ACTIVE_REQUESTS=0) app_model = _make_app(max_active_requests=0) diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py index 2ddb1ea4485..dcbff0dbdf3 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from pytest_mock import MockerFixture from services.rag_pipeline.rag_pipeline import RagPipelineService @@ -9,8 +11,9 @@ def _make_service() -> RagPipelineService: def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled( mocker: MockerFixture, + config_overrides: Callable[..., None], ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False) + config_overrides(MARKETPLACE_ENABLED=False) batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids") service = _make_service() @@ -22,8 +25,9 @@ def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled( def test_fetch_recommended_plugin_manifests_returns_data_when_enabled( mocker: MockerFixture, + config_overrides: Callable[..., None], ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True) + config_overrides(MARKETPLACE_ENABLED=True) expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}] mocker.patch( "services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids", diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py index a2cc34741ef..f7f8e377f41 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py @@ -11,7 +11,7 @@ import json from collections.abc import Generator from contextlib import contextmanager from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock, Mock, call import pytest @@ -96,6 +96,27 @@ def _workflow(session: Session, pipeline: Pipeline, *, graph: dict[str, Any] | N return workflow +def _workflow_for_dependencies( + *, graph: dict[str, Any], environment_variables: list[LLMEnvironmentVariable] | None = None +) -> Workflow: + workflow = Workflow( + id="workflow-dependencies", + tenant_id="tenant-1", + app_id="pipeline-1", + type=WorkflowType.RAG_PIPELINE, + kind=WorkflowKind.STANDARD, + version=Workflow.VERSION_DRAFT, + graph=json.dumps(graph), + features="{}", + created_by="account-1", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + workflow.environment_variables = environment_variables or [] + return workflow + + def _dataset( session: Session, pipeline: Pipeline, @@ -240,8 +261,8 @@ def test_extract_dependencies_from_model_config_covers_models_rerankers_and_tool def test_extract_workflow_dependencies_uses_llm_environment_variable_provider( monkeypatch: pytest.MonkeyPatch, service: RagPipelineDslService ) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow_for_dependencies( + graph={ "nodes": [ { "id": "llm-node", @@ -271,7 +292,7 @@ def test_extract_workflow_dependencies_uses_llm_environment_variable_provider( analyze_dependency, ) - result = service._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = service._extract_dependencies_from_workflow(workflow) assert result == ["new-provider"] analyze_dependency.assert_called_once_with("new-provider") @@ -283,8 +304,8 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe service: RagPipelineDslService, model_selector: list[str], ) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow_for_dependencies( + graph={ "nodes": [ { "id": "llm-node", @@ -300,7 +321,6 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe } ] }, - environment_variables=[], ) analyze_dependency = Mock(side_effect=lambda provider: provider) monkeypatch.setattr( @@ -309,7 +329,7 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe analyze_dependency, ) - result = service._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = service._extract_dependencies_from_workflow(workflow) assert result == ["old-provider"] analyze_dependency.assert_called_once_with("old-provider") diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index cea5bc86a02..a7194a805ef 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -1,5 +1,6 @@ import json import time +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from types import SimpleNamespace @@ -249,9 +250,9 @@ def _make_recommended_plugin(plugin_id: str) -> PipelineRecommendedPlugin: def test_get_pipeline_templates_fallbacks_to_builtin_for_non_english_empty_result( - mocker: MockerFixture, sqlite_session: Session + mocker: MockerFixture, sqlite_session: Session, config_overrides: Callable[..., None] ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") + config_overrides(HOSTED_FETCH_PIPELINE_TEMPLATES_MODE="remote") session = sqlite_session remote_retrieval = mocker.Mock() @@ -290,9 +291,12 @@ def test_get_pipeline_templates_customized_mode_uses_customized_factory( @pytest.mark.parametrize("template_type", ["built-in", "customized"]) def test_get_pipeline_template_detail_uses_expected_mode( - mocker: MockerFixture, template_type: str, sqlite_session: Session + mocker: MockerFixture, + template_type: str, + sqlite_session: Session, + config_overrides: Callable[..., None], ) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") + config_overrides(HOSTED_FETCH_PIPELINE_TEMPLATES_MODE="remote") session = sqlite_session retrieval = mocker.Mock() retrieval.get_pipeline_template_detail.return_value = {"id": "tpl-1"} @@ -1919,8 +1923,10 @@ def test_init_uses_default_sessionmaker_when_none(mocker: MockerFixture, sqlite_ assert exec_session_maker.kw["expire_on_commit"] is False -def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker: MockerFixture, sqlite_session: Session) -> None: - mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") +def test_get_pipeline_templates_builtin_en_us_no_fallback( + mocker: MockerFixture, sqlite_session: Session, config_overrides: Callable[..., None] +) -> None: + config_overrides(HOSTED_FETCH_PIPELINE_TEMPLATES_MODE="remote") session = sqlite_session retrieval = mocker.Mock() retrieval.get_pipeline_templates.return_value = {"pipeline_templates": []} diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py index 8c4485b290c..be5728f396e 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py @@ -1,7 +1,6 @@ import logging from datetime import UTC, datetime from types import SimpleNamespace -from typing import cast import pytest from pytest_mock import MockerFixture @@ -15,6 +14,7 @@ from models.model import UploadFile from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration from services.errors.rag_pipeline import RagPipelineResourceNotFoundError from services.rag_pipeline.rag_pipeline_transform_service import RagPipelineTransformService +from tests.unit_tests.config_override import apply_config_overrides def _dataset(**overrides: object) -> Dataset: @@ -47,6 +47,18 @@ def _document(**overrides: object) -> Document: return Document(**values) +def _pipeline(*, pipeline_id: str = "p-new", tenant_id: str = "t1") -> Pipeline: + pipeline = Pipeline( + tenant_id=tenant_id, + name="Pipeline", + description="", + created_by="user-1", + updated_by="user-1", + ) + pipeline.id = pipeline_id + return pipeline + + def _upload_file(*, file_id: str = "file-1", tenant_id: str = "tenant-1") -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id, @@ -257,14 +269,11 @@ def test_transform_dataset_calls_empty_pipeline_when_no_doc_form( def test_deal_knowledge_index_high_quality_sets_embedding(mocker: MockerFixture) -> None: service = RagPipelineTransformService() - dataset = cast( - Dataset, - SimpleNamespace( - embedding_model="text-embedding-ada-002", - embedding_model_provider="openai", - retrieval_model=None, - summary_index_setting=None, - ), + dataset = _dataset( + embedding_model="text-embedding-ada-002", + embedding_model_provider="openai", + retrieval_model=None, + summary_index_setting=None, ) node = { "data": { @@ -388,7 +397,7 @@ def test_transform_dataset_full_flow(mocker: MockerFixture, sqlite_session: Sess mocker.patch.object(service, "_deal_dependencies") mocker.patch.object(service, "_deal_document_data") - pipeline = SimpleNamespace(id="p-new") + pipeline = _pipeline() create_pipeline = mocker.patch.object(service, "_create_pipeline", return_value=pipeline) result = service.transform_dataset(dataset, "user-1", sqlite_session) @@ -425,7 +434,7 @@ def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create sqlite_session.commit() mocker.patch.object(service, "_get_transform_yaml", return_value={"workflow": {"graph": {"nodes": []}}}) mocker.patch.object(service, "_deal_dependencies") - mocker.patch.object(service, "_create_pipeline", return_value=SimpleNamespace(id="p-new")) + mocker.patch.object(service, "_create_pipeline", return_value=_pipeline()) with pytest.raises(ValueError, match="Unsupported doc form"): service.transform_dataset(dataset, "user-1", sqlite_session) @@ -529,12 +538,9 @@ def _make_service(): def test_deal_dependencies_skips_marketplace_when_disabled( - mocker: MockerFixture, caplog: pytest.LogCaptureFixture + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", - False, - ) + apply_config_overrides(monkeypatch, MARKETPLACE_ENABLED=False) installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value installer.list_plugins.return_value = [] mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration") @@ -559,11 +565,8 @@ def test_deal_dependencies_skips_marketplace_when_disabled( assert any("Marketplace disabled" in rec.message for rec in caplog.records) -def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None: - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", - True, - ) +def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, MARKETPLACE_ENABLED=True) installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value installer.list_plugins.return_value = [] migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value diff --git a/api/tests/unit_tests/services/test_account_email_registration_adapters.py b/api/tests/unit_tests/services/test_account_email_registration_adapters.py new file mode 100644 index 00000000000..14d2fd725f2 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_email_registration_adapters.py @@ -0,0 +1,176 @@ +from unittest.mock import Mock, patch + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from extensions.ext_redis import RedisClientWrapper +from models.account import Account +from services.account_email_registration_adapters import ( + AccountServiceRegistrationGateway, + BillingAccountRegistrationPolicyGateway, + RedisEmailRegistrationSecurityGateway, + TokenManagerEmailRegistrationTokenGateway, +) +from services.account_errors import ( + AccountEmailDomainSuspendedError, + AccountNormalizedEmailAlreadyInUseError, + EmailRegistrationSeatsLimitError, +) +from services.account_service import TokenPair +from services.entities.account_entities import AccountEmailRegistrationPhase, AccountEmailRegistrationToken +from services.errors.account import ( + AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError, +) +from services.errors.account import EmailDomainSuspendedError, SeatsLimitExceededError + + +def test_token_gateway_rejects_malformed_payload() -> None: + gateway = TokenManagerEmailRegistrationTokenGateway() + + with patch( + "services.account_email_registration_adapters.TokenManager.get_token_data", + return_value={"email": "user@example.com", "phase": "unknown"}, + ): + assert gateway.get("token") is None + + +def test_token_gateway_issues_verified_registration_state() -> None: + gateway = TokenManagerEmailRegistrationTokenGateway() + token_data = AccountEmailRegistrationToken( + email="user@example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + + with patch( + "services.account_email_registration_adapters.TokenManager.generate_token", + return_value="token", + ) as generate_token: + assert gateway.issue(token_data) == "token" + + generate_token.assert_called_once_with( + email="user@example.com", + token_type="email_register", + additional_data={"code": "123456", "phase": "register"}, + ) + + +def test_security_gateway_delegates_ip_limit_to_existing_policy_owner() -> None: + redis = Mock(spec=RedisClientWrapper) + gateway = RedisEmailRegistrationSecurityGateway( + redis=redis, + verification_failure_limit=5, + verification_lockout_duration=600, + ) + + with patch( + "services.account_email_registration_adapters.AccountService.is_email_send_ip_limit", + return_value=False, + ) as is_email_send_ip_limit: + assert gateway.is_ip_limited("127.0.0.1") is False + + is_email_send_ip_limit.assert_called_once_with("127.0.0.1") + redis.get.assert_not_called() + + +def test_security_gateway_uses_registration_and_login_keys() -> None: + redis = Mock(spec=RedisClientWrapper) + redis.get.return_value = 1 + gateway = RedisEmailRegistrationSecurityGateway( + redis=redis, + verification_failure_limit=5, + verification_lockout_duration=600, + ) + + with patch( + "services.account_email_registration_adapters.AccountService.reset_login_error_rate_limit" + ) as reset_login_error_rate_limit: + gateway.record_verification_failure("user@example.com") + gateway.reset_verification_failures("user@example.com") + gateway.reset_login_failures("user@example.com") + + redis.setex.assert_called_once_with("email_register_error_rate_limit:user@example.com", 600, 2) + redis.delete.assert_called_once_with("email_register_error_rate_limit:user@example.com") + reset_login_error_rate_limit.assert_called_once_with("user@example.com") + + +def test_billing_policy_is_disabled_outside_cloud() -> None: + gateway = BillingAccountRegistrationPolicyGateway(enabled=False) + + with patch("services.account_email_registration_adapters.BillingService.get_email_freeze_type") as freeze_type: + assert gateway.get_freeze_type("user@example.com") is None + + freeze_type.assert_not_called() + + +@pytest.mark.parametrize( + ("service_error", "application_error"), + [ + pytest.param(SeatsLimitExceededError(), EmailRegistrationSeatsLimitError, id="seat-limit"), + pytest.param(EmailDomainSuspendedError(), AccountEmailDomainSuspendedError, id="suspended-domain"), + pytest.param( + AccountNormalizedEmailAlreadyInUseServiceError(), + AccountNormalizedEmailAlreadyInUseError, + id="normalized-email-in-use", + ), + ], +) +def test_registration_gateway_translates_account_provisioning_errors( + sqlite_session_factory: sessionmaker[Session], + service_error: Exception, + application_error: type[Exception], +) -> None: + gateway = AccountServiceRegistrationGateway(session_factory=sqlite_session_factory) + + with patch( + "services.account_email_registration_adapters.AccountService.create_account_and_tenant", + side_effect=service_error, + ): + with pytest.raises(application_error): + gateway.create( + email="user@example.com", + password="ValidPass123!", + interface_language="en-US", + timezone=None, + ip_address="127.0.0.1", + ) + + +def test_registration_gateway_owns_short_lived_sessions( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + gateway = AccountServiceRegistrationGateway(session_factory=sqlite_session_factory) + + def create_account(*, session: Session, **_: object) -> Account: + account = Account(name="user@example.com", email="user@example.com") + account.id = "account-1" + session.add(account) + session.commit() + return account + + with patch( + "services.account_email_registration_adapters.AccountService.create_account_and_tenant", + side_effect=create_account, + ) as create_account_and_tenant: + account_id = gateway.create( + email="user@example.com", + password="ValidPass123!", + interface_language="en-US", + timezone=None, + ip_address="127.0.0.1", + ) + + assert create_account_and_tenant.call_args.kwargs["check_normalized_email"] is True + sqlite_session.expire_all() + assert sqlite_session.get(Account, account_id) is not None + + with patch( + "services.account_email_registration_adapters.AccountService.login", + return_value=TokenPair(access_token="access", refresh_token="refresh", csrf_token="csrf"), + ) as login: + tokens = gateway.login(account_id, ip_address="127.0.0.1") + + assert tokens.access_token == "access" + assert login.call_args.kwargs["account"].id == account_id + assert isinstance(login.call_args.kwargs["session"], Session) diff --git a/api/tests/unit_tests/services/test_account_email_registration_service.py b/api/tests/unit_tests/services/test_account_email_registration_service.py new file mode 100644 index 00000000000..b3091f51438 --- /dev/null +++ b/api/tests/unit_tests/services/test_account_email_registration_service.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from services.account_email_registration_service import ( + AccountEmailRegistrationService, + AccountRegistrationGateway, + AccountRegistrationPolicyGateway, + EmailRegistrationCodeGenerator, + EmailRegistrationNotificationGateway, + EmailRegistrationSecurityGateway, + EmailRegistrationSendLimiter, + EmailRegistrationTokenGateway, +) +from services.account_errors import ( + AccountEmailAlreadyInUseError, + AccountEmailDomainSuspendedError, + EmailRegistrationPasswordMismatchError, + InvalidEmailRegistrationCodeError, + InvalidEmailRegistrationTokenError, +) +from services.account_ports import AccountRepository +from services.entities.account_entities import ( + AccountEmailRegistrationPhase, + AccountEmailRegistrationToken, + AccountSessionTokens, + AccountSnapshot, +) + + +def _account(*, email: str = "stored@example.com") -> AccountSnapshot: + return AccountSnapshot( + id="account-1", + name="Stored Account", + email=email, + avatar=None, + is_password_set=True, + interface_language="en-US", + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status="active", + initialized_at=datetime(2026, 1, 1), + created_at=datetime(2026, 1, 1), + ) + + +def _service() -> tuple[AccountEmailRegistrationService, dict[str, Mock]]: + dependencies = { + "accounts": Mock(spec=AccountRepository), + "tokens": Mock(spec=EmailRegistrationTokenGateway), + "codes": Mock(spec=EmailRegistrationCodeGenerator), + "notifications": Mock(spec=EmailRegistrationNotificationGateway), + "send_limits": Mock(spec=EmailRegistrationSendLimiter), + "security": Mock(spec=EmailRegistrationSecurityGateway), + "account_policy": Mock(spec=AccountRegistrationPolicyGateway), + "registration": Mock(spec=AccountRegistrationGateway), + } + service = AccountEmailRegistrationService( + accounts=dependencies["accounts"], + tokens=dependencies["tokens"], + codes=dependencies["codes"], + notifications=dependencies["notifications"], + send_limits=dependencies["send_limits"], + security=dependencies["security"], + account_policy=dependencies["account_policy"], + registration=dependencies["registration"], + ) + dependencies["accounts"].find_by_email.return_value = None + dependencies["codes"].generate.return_value = "123456" + dependencies["tokens"].issue.return_value = "token-1" + dependencies["send_limits"].is_limited.return_value = False + dependencies["security"].is_ip_limited.return_value = False + dependencies["security"].is_verification_limited.return_value = False + dependencies["account_policy"].get_freeze_type.return_value = None + return service, dependencies + + +def test_send_code_uses_case_fallback_account_and_existing_account_notification() -> None: + service, dependencies = _service() + dependencies["accounts"].find_by_email.return_value = _account(email="Stored@Example.com") + + token = service.send_code( + remote_ip="127.0.0.1", + requested_email="Stored@Example.com", + requested_language="zh-Hans", + ) + + assert token == "token-1" + dependencies["accounts"].find_by_email.assert_called_once_with("Stored@Example.com") + dependencies["tokens"].issue.assert_called_once_with( + AccountEmailRegistrationToken(email="Stored@Example.com", code="123456") + ) + dependencies["notifications"].send_account_exists.assert_called_once_with( + email="Stored@Example.com", + account_name="Stored Account", + language="zh-Hans", + ) + dependencies["send_limits"].record.assert_called_once_with("Stored@Example.com") + + +def test_send_code_normalizes_new_account_email_and_language() -> None: + service, dependencies = _service() + + service.send_code( + remote_ip="127.0.0.1", + requested_email="New@Example.com", + requested_language="unsupported", + ) + + dependencies["notifications"].send_code.assert_called_once_with( + email="new@example.com", + code="123456", + language="en-US", + ) + + +def test_send_code_rejects_suspended_domain_before_account_lookup() -> None: + service, dependencies = _service() + dependencies["account_policy"].get_freeze_type.return_value = "email_domain_suspended" + + with pytest.raises(AccountEmailDomainSuspendedError): + service.send_code( + remote_ip="127.0.0.1", + requested_email="user@suspended.example", + requested_language=None, + ) + + dependencies["accounts"].find_by_email.assert_not_called() + + +def test_verify_code_rotates_token_into_register_phase() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="User@Example.com", + code="123456", + ) + dependencies["tokens"].issue.return_value = "verified-token" + + verification = service.verify_code( + email="USER@example.com", + code="123456", + token="pending-token", + ) + + assert verification.email == "user@example.com" + assert verification.token == "verified-token" + dependencies["tokens"].revoke.assert_called_once_with("pending-token") + dependencies["tokens"].issue.assert_called_once_with( + AccountEmailRegistrationToken( + email="user@example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + ) + dependencies["security"].reset_verification_failures.assert_called_once_with("user@example.com") + + +def test_verify_code_records_failure_without_consuming_token() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="user@example.com", + code="123456", + ) + + with pytest.raises(InvalidEmailRegistrationCodeError): + service.verify_code(email="user@example.com", code="wrong", token="pending-token") + + dependencies["security"].record_verification_failure.assert_called_once_with("user@example.com") + dependencies["tokens"].revoke.assert_not_called() + + +def test_register_creates_account_and_logs_it_in() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="New@Example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + dependencies["registration"].create.return_value = "account-1" + expected_tokens = AccountSessionTokens(access_token="access", refresh_token="refresh", csrf_token="csrf") + dependencies["registration"].login.return_value = expected_tokens + + tokens = service.register( + remote_ip="127.0.0.1", + token="verified-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + language="zh-Hans", + timezone="Asia/Shanghai", + ) + + assert tokens == expected_tokens + dependencies["tokens"].revoke.assert_called_once_with("verified-token") + dependencies["accounts"].find_by_email.assert_called_once_with("New@Example.com") + dependencies["registration"].create.assert_called_once_with( + email="new@example.com", + password="ValidPass123!", + interface_language="zh-Hans", + timezone="Asia/Shanghai", + ip_address="127.0.0.1", + ) + dependencies["registration"].login.assert_called_once_with("account-1", ip_address="127.0.0.1") + dependencies["security"].reset_login_failures.assert_called_once_with("new@example.com") + + +def test_register_rejects_password_mismatch_before_reading_token() -> None: + service, dependencies = _service() + + with pytest.raises(EmailRegistrationPasswordMismatchError): + service.register( + remote_ip="127.0.0.1", + token="verified-token", + new_password="ValidPass123!", + password_confirm="DifferentPass123!", + language=None, + timezone=None, + ) + + dependencies["tokens"].get.assert_not_called() + + +def test_register_requires_verified_registration_phase() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="new@example.com", + code="123456", + ) + + with pytest.raises(InvalidEmailRegistrationTokenError): + service.register( + remote_ip="127.0.0.1", + token="pending-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + language=None, + timezone=None, + ) + + dependencies["tokens"].revoke.assert_not_called() + + +def test_register_consumes_token_before_rejecting_existing_account() -> None: + service, dependencies = _service() + dependencies["tokens"].get.return_value = AccountEmailRegistrationToken( + email="existing@example.com", + code="123456", + phase=AccountEmailRegistrationPhase.REGISTER, + ) + dependencies["accounts"].find_by_email.return_value = _account(email="existing@example.com") + + with pytest.raises(AccountEmailAlreadyInUseError): + service.register( + remote_ip="127.0.0.1", + token="verified-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + language=None, + timezone=None, + ) + + dependencies["tokens"].revoke.assert_called_once_with("verified-token") + dependencies["registration"].create.assert_not_called() diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 664b96f3b78..a17d285a7f9 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -27,7 +27,7 @@ from services.account_service import ( RegisterService, TenantService, ) -from services.enterprise.rbac_service import MembersInRole, Paginated +from services.enterprise.rbac_service import MemberRolesResponse, MembersInRole, Paginated, RBACRole from services.errors.account import ( AccountAlreadyInTenantError, AccountEmailAlreadyInUseError, @@ -37,6 +37,7 @@ from services.errors.account import ( EmailDomainSuspendedError, NoPermissionError, ) +from tests.unit_tests.config_override import config_overrides_context type _MockDependencies = dict[str, MagicMock] @@ -391,7 +392,7 @@ class TestAccountService: "billing_service" ].get_email_freeze_type.return_value = "email_domain_suspended" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): with pytest.raises(EmailDomainSuspendedError): AccountService.create_account( email="user@suspended.example", @@ -408,7 +409,7 @@ class TestAccountService: "billing_service" ].get_email_freeze_type.return_value = "email_domain_suspended" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): with pytest.raises(EmailDomainSuspendedError): AccountService.get_user_through_email("user@suspended.example", session=unbound_session) @@ -417,9 +418,9 @@ class TestAccountService: ) -> None: mock_external_service_dependencies["billing_service"].get_email_freeze_type.return_value = "freeze" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): assert AccountService.get_account_freeze_type("frozen@example.com") == "freeze" - with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): + with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): assert AccountService.get_account_freeze_type("frozen@example.com") is None mock_external_service_dependencies["billing_service"].get_email_freeze_type.assert_called_once_with( @@ -797,6 +798,14 @@ class TestTenantService: sqlite_session.add(tenant_account_join) return tenant_account_join + def _db_role_of(self, sqlite_session: Session, tenant: Tenant, account_id: str) -> str | None: + return sqlite_session.scalar( + select(TenantAccountJoin.role).where( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.account_id == account_id, + ) + ) + def test_iter_member_account_id_batches_uses_offset_limit(self, sqlite_session: Session) -> None: tenant_id = "00000000-0000-0000-0000-000000000001" account_ids = [ @@ -979,7 +988,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(rbac_task_module.sync_joined_workspace_member_rbac_access_task, "delay", delay), ): TenantService.create_tenant_member( @@ -1012,7 +1021,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch.object(rbac_task_module.sync_joined_workspace_member_rbac_access_task, "delay", delay), ): TenantService.create_tenant_member( @@ -1331,6 +1340,69 @@ class TestTenantService: assert persisted_target_join is not None assert persisted_target_join.role == TenantAccountRole.ADMIN + @pytest.mark.parametrize( + ("outgoing_owner_role_tags", "expected_demoted_role_ids"), + [(["owner", "editor"], ["editor-role-id"]), (["owner"], ["no-access-role-id"])], + ) + def test_update_member_role_to_owner_rbac_enabled( + self, + sqlite_session: Session, + outgoing_owner_role_tags: list[str], + expected_demoted_role_ids: list[str], + config_overrides: Callable[..., None], + ) -> None: + config_overrides(RBAC_ENABLED=True) + tenant = Tenant(name="Test Workspace") + sqlite_session.add(tenant) + sqlite_session.flush() + + operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-1") + candidate = TestAccountAssociatedDataFactory.create_account_mock(account_id="candidate-1") + self._add_tenant_account_join(sqlite_session, tenant, operator.id, TenantAccountRole.EDITOR) + self._add_tenant_account_join(sqlite_session, tenant, candidate.id, TenantAccountRole.EDITOR) + self._add_tenant_account_join(sqlite_session, tenant, "stale-db-owner", TenantAccountRole.OWNER) + sqlite_session.commit() + + outgoing_owner_roles = MemberRolesResponse( + account_id="real-rbac-owner", + roles=[ + RBACRole(id=f"{tag}-role-id", type="workspace", name=tag, role_tag=tag) + for tag in outgoing_owner_role_tags + ], + ) + + with ( + patch( + "services.account_service.AccountService.get_workspace_permission_keys", + return_value={"workspace.role.manage"}, + ), + patch( + "services.account_service.AccountService.get_rbac_workspace_owner_account_id", + return_value="real-rbac-owner", + ), + patch( + "services.account_service.AccountService._resolve_legacy_role_id", + side_effect=lambda *, role, **_kwargs: f"{role.value}-role-id", + ), + patch( + "services.account_service.AccountService._resolve_role_id_by_tag", + return_value="no-access-role-id", + ), + patch("services.account_service.RBACService.MemberRoles.get", return_value=outgoing_owner_roles), + patch("services.account_service.RBACService.MemberRoles.replace") as mock_replace, + ): + TenantService.update_member_role(tenant, candidate, "owner", operator, session=sqlite_session) + + mock_replace.assert_any_call( + tenant_id=tenant.id, + account_id=operator.id, + member_account_id="real-rbac-owner", + role_ids=expected_demoted_role_ids, + session=sqlite_session, + ) + assert self._db_role_of(sqlite_session, tenant, "stale-db-owner") == TenantAccountRole.NORMAL + assert self._db_role_of(sqlite_session, tenant, candidate.id) == TenantAccountRole.OWNER + def test_create_owner_tenant_rbac_enabled_assigns_owner_role( self, sqlite_session: Session, diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py index c2418ecd09a..5a8fea8f4aa 100644 --- a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -36,6 +36,7 @@ from services.agent_app_sandbox_service import ( AgentSandboxInspectorError, WorkflowAgentSandboxService, ) +from tests.unit_tests.config_override import apply_config_overrides def _add_normal_conversation(session: Session, *, binding_id: str) -> Conversation: @@ -687,7 +688,7 @@ def test_workflow_download_resolves_only_exact_active_owner_chain( "FileRequestService", lambda: SimpleNamespace(request_download=request_download), ) - monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example") service = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(cast(Client, client))) result = service.download_file( @@ -847,7 +848,7 @@ def test_workflow_download_uses_authenticated_account_and_trusted_file_request( "services.agent_app_sandbox_service.FileRequestService", lambda: SimpleNamespace(request_download=request_download), ) - monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.FILES_URL", "https://files.example") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example") result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file( tenant_id="tenant-1", @@ -904,7 +905,7 @@ def test_agent_app_download_uses_complete_account_context_after_session_exit( "FileRequestService", lambda: SimpleNamespace(request_download=request_download), ) - monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example") + apply_config_overrides(monkeypatch, FILES_URL="https://files.example") result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).download_file( tenant_id="tenant-1", diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py index bc2a1f84b0b..f69def8bd7c 100644 --- a/api/tests/unit_tests/services/test_agent_config_service.py +++ b/api/tests/unit_tests/services/test_agent_config_service.py @@ -4,6 +4,7 @@ from __future__ import annotations import io import zipfile +from collections.abc import Callable from datetime import datetime from types import SimpleNamespace from unittest.mock import patch @@ -1162,7 +1163,10 @@ def test_resolve_skill_file_member_path_requires_existing_member() -> None: assert exc_info.value.status_code == 404 -def test_download_url_helpers_bind_shared_download_request_to_console_origin() -> None: +def test_download_url_helpers_bind_shared_download_request_to_console_origin( + config_overrides: Callable[..., None], +) -> None: + config_overrides(FILES_URL="https://example.com") service = AgentConfigService() with ( @@ -1174,7 +1178,6 @@ def test_download_url_helpers_bind_shared_download_request_to_console_origin() - ConfigDownloadRequest("guide.txt", "text/plain", 20, "/files/guide.txt?sign=2"), ], ), - patch(f"{MODULE}.dify_config.FILES_URL", "https://example.com"), ): assert ( service.download_skill_url( diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index 18c16d5bfc1..178041d2e48 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -1,3 +1,4 @@ +import json from collections.abc import Callable from types import SimpleNamespace from typing import cast @@ -10,13 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker from core.rbac import RBACPermission, RBACResourceScope from core.workflow.llm_environment_variable import LLMEnvironmentVariable -from models import App, AppMode +from models import Account, App, AppMode, Tenant from models.model import AppModelConfig, AppModelConfigDict, IconType -from models.workflow import Workflow +from models.workflow import Workflow, WorkflowType from services.app_dsl_service import AppDslService, PendingData from services.entities.dsl_entities import ImportStatus from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError +from tests.unit_tests.config_override import apply_config_overrides _OVERWRITE_APP_ID = "11111111-1111-4111-8111-111111111111" _TENANT_ID = "22222222-2222-4222-8222-222222222222" @@ -53,9 +55,59 @@ def _persist_overwrite_target(session: Session, *, maintainer: str = _OTHER_ACCO return app +def _account(*, account_id: str = "account-1", tenant_id: str = "tenant-1") -> Account: + account = Account(name="DSL author", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="DSL workspace") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + +def _app( + *, + app_id: str = "11111111-1111-1111-1111-111111111111", + tenant_id: str = "33333333-3333-3333-3333-333333333333", + mode: AppMode = AppMode.CHAT, + app_model_config_id: str | None = None, +) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + app_model_config_id=app_model_config_id, + name="Existing app", + description="", + mode=mode, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#FFFFFF", + enable_site=True, + enable_api=True, + max_active_requests=0, + use_icon_as_answer_icon=False, + ) + + +def _workflow( + *, graph: dict[str, object], environment_variables: list[LLMEnvironmentVariable] | None = None +) -> Workflow: + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version="draft", + graph=json.dumps(graph), + _features="{}", + created_by="account-1", + ) + workflow.environment_variables = environment_variables or [] + return workflow + + def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow( + graph={ "nodes": [ { "id": "llm-node", @@ -84,7 +136,7 @@ def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(mo analyze_dependency, ) - result = AppDslService._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = AppDslService._extract_dependencies_from_workflow(workflow) assert result == ["new-provider"] analyze_dependency.assert_called_once_with("new-provider") @@ -94,8 +146,8 @@ def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(mo def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_reference( monkeypatch: pytest.MonkeyPatch, model_selector: list[str] ) -> None: - workflow = SimpleNamespace( - graph_dict={ + workflow = _workflow( + graph={ "nodes": [ { "id": "llm-node", @@ -111,7 +163,6 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe } ] }, - environment_variables=[], ) analyze_dependency = Mock(side_effect=lambda provider: provider) monkeypatch.setattr( @@ -119,7 +170,7 @@ def test_extract_workflow_dependencies_tolerates_unresolved_llm_environment_refe analyze_dependency, ) - result = AppDslService._extract_dependencies_from_workflow(cast(Workflow, workflow)) + result = AppDslService._extract_dependencies_from_workflow(workflow) assert result == ["old-provider"] analyze_dependency.assert_called_once_with("old-provider") @@ -130,7 +181,7 @@ def test_import_app_rejects_oversized_yaml_content_before_parsing( ) -> None: monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 3) service = AppDslService(session=unbound_session) - account = Mock(current_tenant_id="tenant-1") + account = _account() result = service.import_app(account=account, import_mode="yaml-content", yaml_content="你你") @@ -150,7 +201,7 @@ def test_import_app_rejects_oversized_yaml_url_bytes_before_decode( service = AppDslService(session=unbound_session) result = service.import_app( - account=Mock(current_tenant_id="tenant-1"), + account=_account(), import_mode="yaml-url", yaml_url="https://example.com/app.yaml", ) @@ -170,7 +221,7 @@ def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes( service = AppDslService(session=unbound_session) result = service.import_app( - account=Mock(current_tenant_id="tenant-1"), + account=_account(), import_mode="yaml-url", yaml_url="https://example.com/app.yaml", ) @@ -192,7 +243,7 @@ def test_import_app_checks_overwrite_rbac_before_database_access( check = Mock(side_effect=deny_before_transaction) setex = Mock() - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) monkeypatch.setattr("services.app_dsl_service.redis_client.setex", setex) @@ -230,7 +281,7 @@ def test_confirm_import_rechecks_overwrite_rbac_before_database_access( return False check = Mock(side_effect=deny_before_transaction) - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) with pytest.raises(NoPermissionError, match="permission to overwrite"): @@ -254,7 +305,7 @@ def test_confirm_import_does_not_create_when_overwrite_target_disappeared( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=_PENDING_DATA_JSON)) - monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=True)) redis_delete = Mock() monkeypatch.setattr("services.app_dsl_service.redis_client.delete", redis_delete) @@ -280,7 +331,7 @@ def test_pending_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch, lambda key, _expiry, value: pending_imports.__setitem__(key, value), ) service = AppDslService(session=unbound_session) - creator = Mock(id="account-1", current_tenant_id="tenant-1") + creator = _account() pending = service.import_app( account=creator, @@ -300,12 +351,12 @@ def test_pending_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch, monkeypatch.setattr( service, "_create_or_update_app", - Mock(return_value=Mock(id="app-1", mode=AppMode.WORKFLOW)), + Mock(return_value=_app(app_id="app-1", mode=AppMode.WORKFLOW)), ) for other_account in ( - Mock(id="account-1", current_tenant_id="tenant-2"), - Mock(id="account-2", current_tenant_id="tenant-1"), + _account(tenant_id="tenant-2"), + _account(account_id="account-2"), ): assert service.confirm_import(import_id=pending.id, account=other_account).status == ImportStatus.FAILED @@ -357,25 +408,13 @@ def test_create_or_update_app_loads_existing_model_config_with_service_session( arrange_session.add(app_model_config) arrange_session.commit() app_model_config_id = app_model_config.id - app = cast( - App, - SimpleNamespace( - id="11111111-1111-1111-1111-111111111111", - tenant_id="33333333-3333-3333-3333-333333333333", - app_model_config_id=app_model_config_id, - name="Existing app", - description="", - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - ), - ) + app = _app(app_model_config_id=app_model_config_id) with sqlite_session_factory() as service_session: result = AppDslService(session=service_session)._create_or_update_app( app=app, data={"app": {"mode": AppMode.CHAT}, "model_config": {"model": {}}}, - account=Mock(id="account-1"), + account=_account(), ) assert result is app @@ -399,25 +438,13 @@ def test_create_or_update_app_flushes_new_model_config_before_signal( signal = Mock() signal.send.side_effect = record_signal monkeypatch.setattr("services.app_dsl_service.app_model_config_was_updated", signal) - app = cast( - App, - SimpleNamespace( - id="11111111-1111-1111-1111-111111111111", - tenant_id="33333333-3333-3333-3333-333333333333", - app_model_config_id=None, - name="Existing app", - description="", - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - ), - ) + app = _app() try: AppDslService(session=sqlite_session)._create_or_update_app( app=app, data={"app": {"mode": AppMode.CHAT}, "model_config": {"model": {}}}, - account=Mock(id="22222222-2222-2222-2222-222222222222"), + account=_account(account_id="22222222-2222-2222-2222-222222222222"), ) finally: event.remove(sqlite_session, "after_flush", record_flush) @@ -504,21 +531,7 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session "services.app_dsl_service.DependenciesAnalysisService.generate_dependencies", Mock(return_value=[]), ) - app = cast( - App, - SimpleNamespace( - id="11111111-1111-1111-1111-111111111111", - tenant_id="33333333-3333-3333-3333-333333333333", - app_model_config_id=app_model_config_id, - mode=AppMode.CHAT, - name="Chat app", - icon_type=IconType.EMOJI, - icon="robot", - icon_background="#FFFFFF", - description="", - use_icon_as_answer_icon=False, - ), - ) + app = _app(app_model_config_id=app_model_config_id) with sqlite_session_factory() as service_session: exported = AppDslService.export_dsl(app, session=service_session) @@ -536,7 +549,7 @@ def test_ensure_agent_manage_permission_noops_when_rbac_disabled( check = Mock() monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) - AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + AppDslService._ensure_agent_manage_permission(_account()) check.assert_not_called() @@ -548,7 +561,7 @@ def test_ensure_agent_manage_permission_allows_agent_manager( check = Mock(return_value=True) monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) - AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + AppDslService._ensure_agent_manage_permission(_account()) check.assert_called_once_with("tenant-1", "account-1", scene=RBACPermission.AGENT_MANAGE) @@ -560,7 +573,7 @@ def test_ensure_agent_manage_permission_rejects_without_agent_manage( monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) with pytest.raises(NoPermissionError): - AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + AppDslService._ensure_agent_manage_permission(_account()) def test_create_or_update_app_gates_agent_mode_before_creation( @@ -576,7 +589,7 @@ def test_create_or_update_app_gates_agent_mode_before_creation( service._create_or_update_app( app=None, data={"app": {"mode": "agent", "name": "Gated agent"}}, - account=Mock(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert not unbound_session.in_transaction() @@ -593,7 +606,7 @@ def test_import_app_reraises_permission_denial_instead_of_failed_result( with pytest.raises(NoPermissionError): service.import_app( - account=Mock(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode="yaml-content", yaml_content="app:\n mode: agent\n name: Denied agent\n", ) @@ -601,18 +614,20 @@ def test_import_app_reraises_permission_denial_instead_of_failed_result( assert not unbound_session.in_transaction() -def test_append_workflow_export_data_reports_missing_selected_workflow(monkeypatch: pytest.MonkeyPatch) -> None: +def test_append_workflow_export_data_reports_missing_selected_workflow( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: workflow_id = "11111111-1111-4111-8111-111111111111" workflow_service = Mock() workflow_service.get_draft_workflow.return_value = None monkeypatch.setattr("services.app_dsl_service.WorkflowService", Mock(return_value=workflow_service)) - app = cast(App, SimpleNamespace(id="app-1", tenant_id="tenant-1")) + app = _app(app_id="app-1", tenant_id="tenant-1") with pytest.raises(WorkflowNotFoundError, match=f"Workflow version not found. Workflow ID: {workflow_id}"): AppDslService._append_workflow_export_data( export_data={}, app_model=app, include_secret=False, - session=Mock(), + session=unbound_session, workflow_id=workflow_id, ) diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index b0bf1a2fd4e..646a30d5cfb 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -21,13 +21,18 @@ from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm import Session import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom from enums import DeploymentEdition, QuotaType from models.model import AppMode from services.app_generate_service import AppGenerateService -from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.app import ( + TriggerWorkflowServiceModeUnavailableError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) # --------------------------------------------------------------------------- @@ -79,10 +84,24 @@ def _make_user() -> MagicMock: return user -def _make_workflow(*, workflow_id: str = "workflow-id", created_by: str = "owner-id") -> MagicMock: +class _RealSessionTest: + @pytest.fixture(autouse=True) + def _bind_unbound_session(self, unbound_session: Session) -> None: + self.session = unbound_session + + +def _make_workflow( + *, + workflow_id: str = "workflow-id", + created_by: str = "owner-id", + node_types: tuple[str, ...] = (), +) -> MagicMock: workflow = MagicMock() workflow.id = workflow_id workflow.created_by = created_by + workflow.walk_nodes.return_value = [ + (f"node-{index}", {"type": node_type}) for index, node_type in enumerate(node_types) + ] return workflow @@ -251,7 +270,7 @@ class TestGetMaxActiveRequests: # --------------------------------------------------------------------------- # generate – every AppMode branch # --------------------------------------------------------------------------- -class TestGenerate: +class TestGenerate(_RealSessionTest): """Tests for AppGenerateService.generate covering each mode.""" @pytest.fixture(autouse=True) @@ -280,7 +299,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) assert result == {"result": "ok"} gen_spy.assert_called_once() @@ -301,7 +320,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) assert result == {"result": "agent"} gen_spy.assert_called_once() @@ -317,7 +336,7 @@ class TestGenerate: side_effect=lambda x: x, ) app = _make_app(AppMode.CHAT, is_agent=True) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=app, user=_make_user(), @@ -340,7 +359,7 @@ class TestGenerate: "services.app_generate_service.AgentAppGenerator.convert_to_event_stream", side_effect=lambda x: x, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=_make_app(AppMode.AGENT), @@ -371,7 +390,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) assert result == {"result": "chat"} gen_spy.assert_called_once() @@ -391,7 +410,7 @@ class TestGenerate: side_effect=lambda x: x, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=_make_app(AppMode.ADVANCED_CHAT), user=_make_user(), @@ -430,7 +449,7 @@ class TestGenerate: args={"workflow_id": None, "query": "hi", "inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=True, - session=MagicMock(), + session=self.session, ) # In streaming mode it should go through retrieve_events, not generate gen_instance.retrieve_events.assert_called_once() @@ -453,7 +472,7 @@ class TestGenerate: side_effect=lambda x: x, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate( app_model=_make_app(AppMode.WORKFLOW), user=_make_user(), @@ -467,6 +486,84 @@ class TestGenerate: assert call_kwargs.get("pause_state_config") is not None assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id" + @pytest.mark.parametrize( + "invoke_from", + [InvokeFrom.OPENAPI, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP], + ) + @pytest.mark.parametrize("node_type", ["trigger-plugin", "trigger-schedule", "trigger-webhook"]) + def test_trigger_workflow_rejects_manual_service_surfaces( + self, + invoke_from: InvokeFrom, + node_type: str, + mocker: MockerFixture, + ) -> None: + workflow = _make_workflow(node_types=(node_type,)) + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + generate = mocker.patch("services.app_generate_service.WorkflowAppGenerator.generate") + + with pytest.raises(TriggerWorkflowServiceModeUnavailableError): + AppGenerateService.generate( + app_model=_make_app(AppMode.WORKFLOW), + user=_make_user(), + args={"inputs": {}}, + invoke_from=invoke_from, + streaming=False, + session=MagicMock(), + ) + + generate.assert_not_called() + + def test_trigger_workflow_allows_trigger_execution(self, mocker: MockerFixture) -> None: + workflow = _make_workflow(node_types=("trigger-webhook",)) + mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + generate = mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.generate", + return_value={"result": "trigger"}, + ) + mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream", + side_effect=lambda value: value, + ) + + result = AppGenerateService.generate( + app_model=_make_app(AppMode.WORKFLOW), + user=_make_user(), + args={"inputs": {}}, + invoke_from=InvokeFrom.TRIGGER, + streaming=False, + session=MagicMock(), + ) + + assert result == {"result": "trigger"} + generate.assert_called_once() + + def test_specific_start_workflow_version_remains_runnable(self, mocker: MockerFixture) -> None: + workflow_id = str(uuid.uuid4()) + workflow = _make_workflow(workflow_id=workflow_id, node_types=("start",)) + get_workflow = mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.generate", + return_value={"result": "version"}, + ) + mocker.patch( + "services.app_generate_service.WorkflowAppGenerator.convert_to_event_stream", + side_effect=lambda value: value, + ) + app = _make_app(AppMode.WORKFLOW) + session = MagicMock() + + result = AppGenerateService.generate( + app_model=app, + user=_make_user(), + args={"inputs": {}, "workflow_id": workflow_id}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + session=session, + ) + + assert result == {"result": "version"} + get_workflow.assert_called_once_with(app, InvokeFrom.SERVICE_API, workflow_id, session=session) + # -- WORKFLOW streaming ------------------------------------------------- def test_workflow_streaming(self, mocker: MockerFixture, config_overrides: Callable[..., None]): config_overrides(PUBSUB_REDIS_CHANNEL_TYPE="streams") @@ -492,7 +589,7 @@ class TestGenerate: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=True, - session=MagicMock(), + session=self.session, ) retrieve_spy.assert_called_once() # Dispatch is gated on subscribe; simulate the SSE layer entering the @@ -511,14 +608,14 @@ class TestGenerate: args={}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) # --------------------------------------------------------------------------- # generate – billing / quota # --------------------------------------------------------------------------- -class TestGenerateBilling: +class TestGenerateBilling(_RealSessionTest): @pytest.fixture(autouse=True) def _common(self, mocker: MockerFixture): mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit) @@ -549,7 +646,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") quota_charge.commit.assert_called_once() @@ -573,7 +670,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) def test_exception_refunds_quota_and_exits_rate_limit( @@ -601,7 +698,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) quota_charge.refund.assert_called_once() @@ -633,7 +730,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) # exit is called in finally block for non-streaming assert exit_calls == ["dummy-request-id"] @@ -664,7 +761,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=False, - session=MagicMock(), + session=self.session, ) quota_charge.refund.assert_called_once() @@ -698,7 +795,7 @@ class TestGenerateBilling: args={"inputs": {}}, invoke_from=InvokeFrom.SERVICE_API, streaming=True, - session=MagicMock(), + session=self.session, ) quota_charge.refund.assert_called_once() @@ -708,14 +805,16 @@ class TestGenerateBilling: # --------------------------------------------------------------------------- # _get_workflow # --------------------------------------------------------------------------- -class TestGetWorkflow: +class TestGetWorkflow(_RealSessionTest): def test_debugger_fetches_draft(self, mocker: MockerFixture): draft_wf = _make_workflow() ws = MagicMock() ws.get_draft_workflow.return_value = draft_wf mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) - result = AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=MagicMock()) + result = AppGenerateService._get_workflow( + _make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=self.session + ) assert result is draft_wf ws.get_draft_workflow.assert_called_once() @@ -725,7 +824,7 @@ class TestGetWorkflow: mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) with pytest.raises(ValueError, match="Workflow not initialized"): - AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=MagicMock()) + AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER, session=self.session) def test_non_debugger_fetches_published(self, mocker: MockerFixture): pub_wf = _make_workflow() @@ -734,7 +833,7 @@ class TestGetWorkflow: mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) result = AppGenerateService._get_workflow( - _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=MagicMock() + _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=self.session ) assert result is pub_wf ws.get_published_workflow.assert_called_once() @@ -745,7 +844,7 @@ class TestGetWorkflow: mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) with pytest.raises(ValueError, match="Workflow not published"): - AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=MagicMock()) + AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, session=self.session) def test_specific_workflow_id_valid_uuid(self, mocker: MockerFixture): valid_uuid = str(uuid.uuid4()) @@ -758,7 +857,7 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id=valid_uuid, - session=MagicMock(), + session=self.session, ) assert result is specific_wf ws.get_published_workflow_by_id.assert_called_once() @@ -772,7 +871,7 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id="not-a-uuid", - session=MagicMock(), + session=self.session, ) def test_specific_workflow_id_not_found(self, mocker: MockerFixture): @@ -786,14 +885,14 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id=valid_uuid, - session=MagicMock(), + session=self.session, ) # --------------------------------------------------------------------------- # generate_single_iteration # --------------------------------------------------------------------------- -class TestGenerateSingleIteration: +class TestGenerateSingleIteration(_RealSessionTest): def test_advanced_chat_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) @@ -806,7 +905,7 @@ class TestGenerateSingleIteration: return_value={"event": "iteration"}, ) app = _make_app(AppMode.ADVANCED_CHAT) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_iteration( app_model=app, user=_make_user(), @@ -830,7 +929,7 @@ class TestGenerateSingleIteration: return_value={"event": "wf-iteration"}, ) app = _make_app(AppMode.WORKFLOW) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_iteration( app_model=app, user=_make_user(), @@ -846,14 +945,14 @@ class TestGenerateSingleIteration: app = _make_app(AppMode.CHAT) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate_single_iteration( - app_model=app, user=_make_user(), node_id="n1", args={}, session=MagicMock() + app_model=app, user=_make_user(), node_id="n1", args={}, session=self.session ) # --------------------------------------------------------------------------- # generate_single_loop # --------------------------------------------------------------------------- -class TestGenerateSingleLoop: +class TestGenerateSingleLoop(_RealSessionTest): def test_advanced_chat_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) @@ -866,7 +965,7 @@ class TestGenerateSingleLoop: return_value={"event": "loop"}, ) app = _make_app(AppMode.ADVANCED_CHAT) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_loop( app_model=app, user=_make_user(), @@ -890,7 +989,7 @@ class TestGenerateSingleLoop: return_value={"event": "wf-loop"}, ) app = _make_app(AppMode.WORKFLOW) - session = MagicMock() + session = self.session result = AppGenerateService.generate_single_loop( app_model=app, user=_make_user(), @@ -906,20 +1005,20 @@ class TestGenerateSingleLoop: app = _make_app(AppMode.COMPLETION) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate_single_loop( - app_model=app, user=_make_user(), node_id="n1", args=MagicMock(), session=MagicMock() + app_model=app, user=_make_user(), node_id="n1", args=MagicMock(), session=self.session ) # --------------------------------------------------------------------------- # generate_more_like_this # --------------------------------------------------------------------------- -class TestGenerateMoreLikeThis: +class TestGenerateMoreLikeThis(_RealSessionTest): def test_delegates_to_completion_generator(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.CompletionAppGenerator.generate_more_like_this", return_value={"result": "similar"}, ) - session = MagicMock() + session = self.session result = AppGenerateService.generate_more_like_this( app_model=_make_app(AppMode.COMPLETION), user=_make_user(), diff --git a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py index fe667ef5771..1595a1db952 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py +++ b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py @@ -9,6 +9,7 @@ from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.message_generator import MessageGenerator from models.model import AppMode from services.app_generate_service import AppGenerateService +from tests.unit_tests.config_override import apply_config_overrides # ----------------------------- @@ -114,10 +115,7 @@ def _patch_get_channel_streams(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("extensions.ext_redis.get_pubsub_broadcast_channel", lambda: chan) monkeypatch.setattr("core.app.apps.message_generator.get_pubsub_broadcast_channel", lambda: chan) monkeypatch.setattr("core.app.apps.message_based_app_generator.get_pubsub_broadcast_channel", lambda: chan) - # Ensure AppGenerateService sees streams mode - import services.app_generate_service as ags - - monkeypatch.setattr(ags.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams", raising=False) + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="streams") @pytest.fixture @@ -134,10 +132,7 @@ def _patch_get_channel_pubsub(monkeypatch: pytest.MonkeyPatch): # Patch both the source and the imported alias used by MessageGenerator monkeypatch.setattr("extensions.ext_redis.get_pubsub_broadcast_channel", lambda: chan) monkeypatch.setattr("core.app.apps.message_generator.get_pubsub_broadcast_channel", lambda: chan) - # Ensure AppGenerateService sees pubsub mode - import services.app_generate_service as ags - - monkeypatch.setattr(ags.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub", raising=False) + apply_config_overrides(monkeypatch, PUBSUB_REDIS_CHANNEL_TYPE="pubsub") def _publish_events(app_mode: AppMode, run_id: str, events: list[dict]): diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 8c71979a726..b9bf28be032 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -114,7 +114,10 @@ def _persist_agent_app( class TestCreateAppTransactionBoundary: - def test_commits_database_state_before_external_side_effects(self, sqlite_session: Session) -> None: + def test_commits_database_state_before_external_side_effects( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) account = _persist_account(sqlite_session) phase_events: list[str] = [] event.listen(sqlite_session, "after_commit", lambda _session: phase_events.append("commit")) @@ -132,7 +135,6 @@ class TestCreateAppTransactionBoundary: "services.app_service.FeatureService.get_system_features", return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ), - patch("services.app_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): app = AppService().create_app( account.current_tenant_id, @@ -174,7 +176,10 @@ class TestCreateAppTransactionBoundary: assert sqlite_session.scalars(select(AppModelConfig)).all() == [] assert sqlite_session.get(Agent, existing_agent.id) is existing_agent - def test_falls_back_when_default_model_schema_is_unavailable(self, sqlite_session: Session) -> None: + def test_falls_back_when_default_model_schema_is_unavailable( + self, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) account = _persist_account(sqlite_session) model_type_instance = MagicMock() model_type_instance.get_model_schema.side_effect = ValueError("Base model unknown-model not found") @@ -195,7 +200,6 @@ class TestCreateAppTransactionBoundary: "services.app_service.FeatureService.get_system_features", return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ), - patch("services.app_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): app = AppService().create_app( account.current_tenant_id, diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index 1763cfab7b9..18229ac3fb1 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -1,5 +1,6 @@ """Unit tests for DocumentService behaviors in dataset_service.""" +from collections.abc import Callable from datetime import datetime from sqlalchemy import event, select @@ -1117,13 +1118,18 @@ class TestDocumentServiceSaveDocumentWithDatasetId: check_quota.assert_not_called() - def test_save_document_with_dataset_id_enforces_batch_upload_limit(self, account_context, unbound_session: Session): + def test_save_document_with_dataset_id_enforces_batch_upload_limit( + self, + account_context, + unbound_session: Session, + config_overrides: Callable[..., None], + ): + config_overrides(BATCH_UPLOAD_LIMIT=1) dataset = _dataset_row() knowledge_config = _make_upload_knowledge_config(file_ids=["file-1", "file-2"]) with ( patch("services.dataset_service.FeatureService.get_features", return_value=_make_features(enabled=True)), - patch("services.dataset_service.dify_config.BATCH_UPLOAD_LIMIT", 1), patch.object(DocumentService, "check_documents_upload_quota") as check_quota, ): with pytest.raises(ValueError, match="batch upload limit of 1"): @@ -1706,8 +1712,12 @@ class TestDocumentServiceSaveWithoutDatasetBilling: yield account def test_save_document_without_dataset_id_counts_notion_pages_for_quota( - self, account_context, sqlite_session: Session + self, + account_context, + sqlite_session: Session, + config_overrides: Callable[..., None], ): + config_overrides(BATCH_UPLOAD_LIMIT="10") knowledge_config = KnowledgeConfig( indexing_technique="economy", data_source=DataSource( @@ -1736,7 +1746,6 @@ class TestDocumentServiceSaveWithoutDatasetBilling: with ( patch("services.dataset_service.FeatureService.get_features", return_value=features), - patch("services.dataset_service.dify_config.BATCH_UPLOAD_LIMIT", "10"), patch.object(DocumentService, "check_documents_upload_quota") as check_quota, patch.object( DocumentService, @@ -1755,8 +1764,12 @@ class TestDocumentServiceSaveWithoutDatasetBilling: assert sqlite_session.get(Dataset, dataset.id) is dataset def test_save_document_without_dataset_id_enforces_batch_limit_for_website_urls( - self, account_context, unbound_session: Session + self, + account_context, + unbound_session: Session, + config_overrides: Callable[..., None], ): + config_overrides(BATCH_UPLOAD_LIMIT="1") knowledge_config = KnowledgeConfig( indexing_technique="economy", data_source=DataSource( @@ -1774,7 +1787,6 @@ class TestDocumentServiceSaveWithoutDatasetBilling: with ( patch("services.dataset_service.FeatureService.get_features", return_value=_make_features(enabled=True)), - patch("services.dataset_service.dify_config.BATCH_UPLOAD_LIMIT", "1"), patch.object(DocumentService, "check_documents_upload_quota") as check_quota, ): with pytest.raises(ValueError, match="batch upload limit of 1"): diff --git a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py index 50ca483b976..76dc9f584d2 100644 --- a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py +++ b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py @@ -1,5 +1,5 @@ import types -from unittest.mock import Mock, create_autospec +from unittest.mock import Mock import pytest from redis.exceptions import LockNotOwnedError @@ -203,19 +203,48 @@ def test_add_segment_ignores_lock_not_owned( # --------------------------------------------------------------------------- +@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, Dataset, Document, DocumentSegment)], indirect=True) def test_multi_create_segment_ignores_lock_not_owned( monkeypatch: pytest.MonkeyPatch, fake_current_user, fake_lock, + sqlite_session: Session, ): # Arrange - dataset = create_autospec(Dataset, instance=True) - dataset.id = "ds-1" - dataset.tenant_id = fake_current_user.current_tenant_id - dataset.indexing_technique = IndexTechniqueType.ECONOMY # again, skip high_quality path + dataset = Dataset( + id=DATASET_ID, + tenant_id=TENANT_ID, + name="Test Dataset", + description="", + created_by=USER_ID, + indexing_technique=IndexTechniqueType.ECONOMY, + ) + document = Document( + id=DOCUMENT_ID, + tenant_id=TENANT_ID, + dataset_id=DATASET_ID, + position=1, + data_source_type="upload_file", + data_source_info="{}", + batch="batch-1", + name="Test Document", + created_from="web", + created_by=USER_ID, + word_count=0, + doc_form=IndexStructureType.QA_INDEX, + ) + sqlite_session.add_all([fake_current_user._current_tenant, fake_current_user, dataset, document]) + sqlite_session.commit() - document = create_autospec(Document, instance=True) - document.id = "doc-1" - document.dataset_id = dataset.id - document.word_count = 0 - document.doc_form = IndexStructureType.QA_INDEX + result = SegmentService.multi_create_segment( + segments=[{"content": "question", "answer": "answer", "keywords": ["key"]}], + document=document, + dataset=dataset, + session=sqlite_session, + ) + + assert result is None + assert not sqlite_session.in_transaction() + assert sqlite_session.scalar(select(func.count(DocumentSegment.id))) == 0 + sqlite_session.refresh(document) + assert document.word_count == 0 diff --git a/api/tests/unit_tests/services/test_dataset_service_segment.py b/api/tests/unit_tests/services/test_dataset_service_segment.py index 2e2ad0aa774..5598361f977 100644 --- a/api/tests/unit_tests/services/test_dataset_service_segment.py +++ b/api/tests/unit_tests/services/test_dataset_service_segment.py @@ -1,5 +1,7 @@ """Unit tests for SegmentService behaviors in dataset_service.""" +from collections.abc import Callable + from services.dataset_ref_service import DatasetRef, DatasetRefService, DocumentRef, SegmentRef from .dataset_service_test_helpers import ( @@ -471,13 +473,13 @@ class TestSegmentServiceValidation: with pytest.raises(ValueError, match="Content is empty"): SegmentService.segment_create_args_validate({"content": " "}, document) - def test_segment_create_args_validate_enforces_attachment_limit(self): + def test_segment_create_args_validate_enforces_attachment_limit(self, config_overrides: Callable[..., None]): + config_overrides(SINGLE_CHUNK_ATTACHMENT_LIMIT=1) document = _make_document(doc_form=IndexStructureType.PARAGRAPH_INDEX) args = {"content": "hello", "attachment_ids": ["a-1", "a-2"]} - with patch("services.dataset_service.dify_config.SINGLE_CHUNK_ATTACHMENT_LIMIT", 1): - with pytest.raises(ValueError, match="Exceeded maximum attachment limit of 1"): - SegmentService.segment_create_args_validate(args, document) + with pytest.raises(ValueError, match="Exceeded maximum attachment limit of 1"): + SegmentService.segment_create_args_validate(args, document) def test_segment_create_args_validate_requires_attachment_ids_list(self): document = _make_document(doc_form=IndexStructureType.PARAGRAPH_INDEX) diff --git a/api/tests/unit_tests/services/test_email_code_login_challenge.py b/api/tests/unit_tests/services/test_email_code_login_challenge.py index 761c1bffe66..5ea11346eda 100644 --- a/api/tests/unit_tests/services/test_email_code_login_challenge.py +++ b/api/tests/unit_tests/services/test_email_code_login_challenge.py @@ -10,6 +10,7 @@ from services.email_code_login_challenge import ( EmailCodeLoginChallengeStore, EmailCodeLoginChallengeUnavailableError, ) +from tests.unit_tests.config_override import apply_config_overrides TOKEN = "00000000-0000-4000-8000-000000000001" @@ -23,8 +24,11 @@ def challenge_redis() -> Iterator[MagicMock]: def test_create_stores_only_one_per_email_v2_challenge( challenge_redis: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS", 5) - monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES", 5) + apply_config_overrides( + monkeypatch, + EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5, + EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5, + ) with patch("services.email_code_login_challenge.uuid.uuid4", return_value=TOKEN): token = EmailCodeLoginChallengeStore.create( diff --git a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py index 089464da9f0..b31e398add4 100644 --- a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py +++ b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py @@ -57,12 +57,11 @@ def test_get_system_features_uses_configured_deployment_edition( ], ) def test_trial_app_policy_is_cloud_only( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], edition: DeploymentEdition, feature_enabled: bool, expected: bool, ) -> None: - monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", edition) - monkeypatch.setattr("services.feature_service.dify_config.ENABLE_TRIAL_APP", feature_enabled) + config_overrides(DEPLOYMENT_EDITION=edition, ENABLE_TRIAL_APP=feature_enabled) assert FeatureService.is_trial_app_enabled() is expected diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py index fb9ebf6e9be..8929d49702f 100644 --- a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py @@ -2,7 +2,8 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from collections.abc import Callable +from unittest.mock import MagicMock from uuid import uuid4 import pytest @@ -10,7 +11,6 @@ from flask import Flask from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from configs import dify_config from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, @@ -45,17 +45,17 @@ def _make_valid_email_config(): ) -def test_build_form_link(): - with patch.object(dify_config, "APP_WEB_URL", "http://example.com/"): - assert _build_form_link("token123") == "http://example.com/form/token123" +def test_build_form_link(config_overrides: Callable[..., None]): + config_overrides(APP_WEB_URL="http://example.com/") + assert _build_form_link("token123") == "http://example.com/form/token123" - with patch.object(dify_config, "APP_WEB_URL", "http://example.com"): - assert _build_form_link("token123") == "http://example.com/form/token123" + config_overrides(APP_WEB_URL="http://example.com") + assert _build_form_link("token123") == "http://example.com/form/token123" assert _build_form_link(None) is None - with patch.object(dify_config, "APP_WEB_URL", None): - assert _build_form_link("token123") is None + config_overrides(APP_WEB_URL=None) + assert _build_form_link("token123") is None class TestDeliveryTestRegistry: @@ -320,7 +320,8 @@ class TestEmailDeliveryTestHandler: handler = EmailDeliveryTestHandler(session_factory=sqlite_engine) assert handler._query_workspace_member_emails(tenant_id="t1", user_ids=[]) == {} - def test_build_substitutions(self): + def test_build_substitutions(self, config_overrides: Callable[..., None]): + config_overrides(APP_WEB_URL="http://example.com") context = DeliveryTestContext( tenant_id="t1", app_id="a1", @@ -331,8 +332,7 @@ class TestEmailDeliveryTestHandler: recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")], ) - with patch.object(dify_config, "APP_WEB_URL", "http://example.com"): - subs = EmailDeliveryTestHandler._build_substitutions(context=context, recipient_email="test@example.com") + subs = EmailDeliveryTestHandler._build_substitutions(context=context, recipient_email="test@example.com") assert subs["node_title"] == "title" assert subs["form_content"] == "content" diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 8760e6cb957..5c42999ae74 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -40,6 +40,7 @@ from services.human_input_service import ( HumanInputService, InvalidFormDataError, ) +from tests.unit_tests.config_override import apply_config_overrides def _make_app(mode: AppMode) -> App: @@ -130,7 +131,7 @@ def test_ensure_form_active_respects_global_timeout( created_at=naive_utc_now() - timedelta(hours=2), expiration_time=naive_utc_now() + timedelta(hours=2), ) - monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=3600) with pytest.raises(FormExpiredError): service.ensure_form_active(Form(expired_record)) @@ -701,7 +702,7 @@ def test_is_globally_expired_zero_timeout( ) -> None: service = HumanInputService(unbound_session_factory) - monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=0) assert service._is_globally_expired(Form(sample_form_record)) is False diff --git a/api/tests/unit_tests/services/test_knowledge_fs_proxy.py b/api/tests/unit_tests/services/test_knowledge_fs_proxy.py index 4d7e67f6d60..51cd56b7bfd 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_proxy.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_proxy.py @@ -223,14 +223,15 @@ def _set_config( timeout_seconds: float = 7.5, jwt_secret: str | None = _JWT_SECRET, ) -> None: + from tests.unit_tests.config_override import apply_config_overrides + values = { "KNOWLEDGE_FS_BASE_URL": base_url, "KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS": sse_read_timeout_seconds, "KNOWLEDGE_FS_TIMEOUT_SECONDS": timeout_seconds, "KNOWLEDGE_FS_JWT_SECRET": SecretStr(jwt_secret) if jwt_secret is not None else None, } - for name, value in values.items(): - monkeypatch.setattr(f"services.knowledge_fs_proxy.dify_config.{name}", value, raising=False) + apply_config_overrides(monkeypatch, **values) def _processing_task_events_path() -> str: diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py index 0de7fe2c9c4..2c2f86bbf40 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -387,7 +387,9 @@ class TestModelProviderServiceConfiguration: def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status( self, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) state = _ProviderSummaryState(has_custom_provider=True) preferred_provider_type = ModelProviderService._get_preferred_provider_type( diff --git a/api/tests/unit_tests/services/test_notification_gateway.py b/api/tests/unit_tests/services/test_notification_gateway.py new file mode 100644 index 00000000000..9df67e7a325 --- /dev/null +++ b/api/tests/unit_tests/services/test_notification_gateway.py @@ -0,0 +1,63 @@ +from unittest.mock import patch + +from services.entities.notification_entities import NotificationContent +from services.notification_gateway import BillingNotificationGateway + + +def test_get_active_maps_billing_proto_json_contract() -> None: + payload = { + "shouldShow": True, + "notifications": [ + { + "notificationId": "notification-1", + "frequency": "once", + "contents": { + "en-US": { + "lang": "en-US", + "title": "Title", + "subtitle": "Subtitle", + "body": "Body", + "titlePicUrl": "title.png", + } + }, + } + ], + } + + with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload): + result = BillingNotificationGateway().get_active("account-1") + + assert result.should_show is True + assert result.notifications[0].notification_id == "notification-1" + assert result.notifications[0].contents["en-US"].title_pic_url == "title.png" + + +def test_get_active_omits_empty_localized_content_so_service_can_fall_back() -> None: + empty_localized_content: dict[str, str] = {} + payload = { + "shouldShow": True, + "notifications": [ + { + "notificationId": "notification-1", + "frequency": "once", + "contents": { + "zh-Hans": empty_localized_content, + "en-US": {"lang": "en-US", "title": "Title"}, + }, + } + ], + } + + with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload): + result = BillingNotificationGateway().get_active("account-1") + + assert result.notifications[0].contents == { + "en-US": NotificationContent("en-US", "Title", "", "", ""), + } + + +def test_dismiss_delegates_to_billing_service() -> None: + with patch("services.notification_gateway.BillingService.dismiss_notification") as dismiss: + BillingNotificationGateway().dismiss("notification-1", "account-1") + + dismiss.assert_called_once_with(notification_id="notification-1", account_id="account-1") diff --git a/api/tests/unit_tests/services/test_notification_service.py b/api/tests/unit_tests/services/test_notification_service.py new file mode 100644 index 00000000000..3be7f08a6f7 --- /dev/null +++ b/api/tests/unit_tests/services/test_notification_service.py @@ -0,0 +1,138 @@ +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.account_entities import AccountSnapshot +from services.entities.notification_entities import ( + AccountNotification, + AccountNotificationBatch, + NotificationContent, + NotificationItem, + NotificationResult, +) +from services.notification_service import NotificationService + + +def _context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id="workspace-1", + ) + + +class NotificationGatewayStub: + def __init__(self, batch: AccountNotificationBatch) -> None: + self.batch = batch + self.get_account_ids: list[str] = [] + self.dismissals: list[tuple[str, str]] = [] + + def get_active(self, account_id: str) -> AccountNotificationBatch: + self.get_account_ids.append(account_id) + return self.batch + + def dismiss(self, notification_id: str, account_id: str) -> None: + self.dismissals.append((notification_id, account_id)) + + +def _account(language: str | None = "zh-Hans") -> AccountSnapshot: + return AccountSnapshot( + id="account-1", + name="Account", + email="account@example.com", + avatar=None, + is_password_set=False, + interface_language=language, + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status="active", + initialized_at=None, + created_at=datetime(2026, 1, 1), + ) + + +def _accounts(account: AccountSnapshot | None) -> Mock: + accounts = Mock(spec=AccountRepository) + accounts.get.return_value = account + return accounts + + +def _notification(contents: dict[str, NotificationContent]) -> AccountNotification: + return AccountNotification( + notification_id="notification-1", + frequency="once", + contents=contents, + ) + + +def test_get_active_localizes_notification_for_account_language() -> None: + chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png") + english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") + gateway = NotificationGatewayStub( + AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),)) + ) + service = NotificationService(accounts=_accounts(_account()), notifications=gateway) + + result = service.get_active(_context()) + + assert result == NotificationResult( + should_show=True, + notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),), + ) + assert gateway.get_account_ids == ["account-1"] + + +def test_get_active_falls_back_to_english() -> None: + english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png") + gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),))) + service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway) + + result = service.get_active(_context()) + + assert result.notifications[0].lang == "en-US" + assert result.notifications[0].title == "Title" + + +def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None: + accounts = _accounts(None) + service = NotificationService( + accounts=accounts, + notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())), + ) + + result = service.get_active(_context()) + + assert result == NotificationResult(False, ()) + accounts.get.assert_not_called() + + +def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None: + gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),))) + service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway) + + result = service.get_active(_context()) + + assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),) + + +def test_get_active_rejects_unknown_admitted_account() -> None: + gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),))) + service = NotificationService(accounts=_accounts(None), notifications=gateway) + + with pytest.raises(RuntimeError, match="unknown account"): + service.get_active(_context()) + + +def test_dismiss_delegates_identifiers_to_gateway() -> None: + gateway = NotificationGatewayStub(AccountNotificationBatch(False, ())) + service = NotificationService(accounts=_accounts(_account()), notifications=gateway) + + service.dismiss(_context(), "notification-1") + + assert gateway.dismissals == [("notification-1", "account-1")] diff --git a/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py b/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py index bcd48c1b2d3..82a74d8d551 100644 --- a/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py +++ b/api/tests/unit_tests/services/test_recommended_app_catalog_gateway.py @@ -18,6 +18,7 @@ from services.recommended_app_query_service import ( RecommendedAppInfoRecord, RecommendedAppRecord, ) +from tests.unit_tests.config_override import apply_config_overrides def _page_payload(*app_ids: str, learn_dify_ids: frozenset[str] = frozenset()) -> dict[str, object]: @@ -224,7 +225,7 @@ class TestRemoteRecommendedAppCatalogGateway: @pytest.fixture(autouse=True) def _use_remote_mode(self, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: gateway_module.clear_remote_fetch_cache() - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") yield gateway_module.clear_remote_fetch_cache() @@ -445,12 +446,11 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _detail_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr( - gateway_module.dify_config, - "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", - "https://catalog.example.com", + apply_config_overrides( + monkeypatch, + HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com", + CONSOLE_WEB_URL="https://console.example.com", ) - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://console.example.com") gateway = RemoteRecommendedAppCatalogGateway() gateway.get_detail("app-1") @@ -466,12 +466,11 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _page_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr( - gateway_module.dify_config, - "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", - "https://catalog.example.com", + apply_config_overrides( + monkeypatch, + HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com", + HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) gateway = RemoteRecommendedAppCatalogGateway() assert gateway.list_recommended("en-US") == _expected_page() @@ -482,7 +481,7 @@ class TestRemoteRecommendedAppCatalogGateway: response = MagicMock(status_code=500) http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) expected_page = _expected_page() fallback = MagicMock() fallback.list_recommended.return_value = expected_page @@ -501,7 +500,7 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _page_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 0) + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=0) gateway = RemoteRecommendedAppCatalogGateway() gateway.list_recommended("en-US") @@ -516,11 +515,11 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _page_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL", 600) + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600) gateway = RemoteRecommendedAppCatalogGateway() - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://cloud-a.example.com") + apply_config_overrides(monkeypatch, CONSOLE_WEB_URL="https://cloud-a.example.com") gateway.list_recommended("en-US") - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", "https://cloud-b.example.com") + apply_config_overrides(monkeypatch, CONSOLE_WEB_URL="https://cloud-b.example.com") gateway.list_recommended("en-US") assert http_get.call_count == 2 @@ -543,7 +542,7 @@ class TestRemoteRecommendedAppCatalogGateway: response.json.return_value = _detail_payload() http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr(gateway_module.dify_config, "CONSOLE_WEB_URL", console_web_url) + apply_config_overrides(monkeypatch, CONSOLE_WEB_URL=console_web_url) gateway = RemoteRecommendedAppCatalogGateway() gateway.get_detail("app-1") @@ -565,10 +564,9 @@ class TestRemoteRecommendedAppCatalogGateway: response = MagicMock(status_code=500) http_get = MagicMock(return_value=response) monkeypatch.setattr(gateway_module.httpx, "get", http_get) - monkeypatch.setattr( - gateway_module.dify_config, - "HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN", - "https://catalog.example.com", + apply_config_overrides( + monkeypatch, + HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com", ) fallback = MagicMock() database = MagicMock() @@ -603,7 +601,7 @@ class TestRecommendedAppCatalogRouter: database=MagicMock(), builtin=builtin, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") assert gateway.list_recommended("ja-JP") == expected_page remote.list_recommended.assert_called_once_with("ja-JP") @@ -619,13 +617,13 @@ class TestRecommendedAppCatalogRouter: builtin=builtin, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") gateway.list_recommended("en-US") - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "db") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="db") gateway.list_learn_dify("en-US") - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin") gateway.get_detail("app-1") - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote") gateway.contains("app-1") remote.list_recommended.assert_called_once_with("en-US") @@ -643,7 +641,7 @@ class TestRecommendedAppCatalogRouter: database=database, builtin=builtin, ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "builtin") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin") assert gateway.list_learn_dify("en-US") == expected_page builtin.list_learn_dify.assert_called_once_with("en-US") @@ -655,7 +653,7 @@ class TestRecommendedAppCatalogRouter: database=MagicMock(), builtin=MagicMock(), ) - monkeypatch.setattr(gateway_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "invalid") + apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="invalid") with pytest.raises(ValueError, match="invalid fetch recommended apps mode: invalid"): gateway.list_recommended("en-US") diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index 3ce4a745b7b..8e9fd12529b 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -64,6 +64,7 @@ from services.skill_management_service import ( validate_skill_description, validate_skill_name, ) +from tests.unit_tests.config_override import apply_config_overrides TENANT = "11111111-1111-1111-1111-111111111111" AGENT = "22222222-2222-2222-2222-222222222222" @@ -2682,7 +2683,7 @@ def test_import_skill_package_rejects_missing_frontmatter_description() -> None: def test_import_skill_package_rejects_archive_larger_than_upload_skill_limit(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.skill_management_service.dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT", 0) + apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=0) service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py index f213dd95267..b1318b1edce 100644 --- a/api/tests/unit_tests/services/test_snippet_dsl_service.py +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -1,9 +1,15 @@ +import json from types import SimpleNamespace from unittest.mock import Mock import pytest +from sqlalchemy import event +from sqlalchemy.orm import Session from graphon.nodes import BuiltinNodeTypes +from models import Account, Tenant +from models.snippet import CustomizedSnippet, SnippetType +from models.workflow import Workflow, WorkflowType from services.snippet_dsl_service import ( ImportMode, ImportStatus, @@ -12,6 +18,62 @@ from services.snippet_dsl_service import ( _check_version_compatibility, ) +SQLITE_MODELS = (CustomizedSnippet,) +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), +] + + +@pytest.fixture +def service(sqlite_session: Session) -> SnippetDslService: + """Create the service with a real caller-owned SQLite session.""" + return SnippetDslService(session=sqlite_session) + + +def _account(*, account_id: str = "account-1", tenant_id: str = "tenant-1") -> Account: + account = Account(name="Snippet author", email=f"{account_id}@example.com") + account.id = account_id + tenant = Tenant(name="Snippet workspace") + tenant.id = tenant_id + account._current_tenant = tenant + return account + + +def _snippet( + *, + snippet_id: str = "snippet-1", + tenant_id: str = "tenant-1", + name: str = "Snippet", + description: str | None = None, + snippet_type: SnippetType = SnippetType.NODE, + icon_info: dict | None = None, + input_fields: list[dict] | None = None, +) -> CustomizedSnippet: + return CustomizedSnippet( + id=snippet_id, + tenant_id=tenant_id, + name=name, + description=description, + type=snippet_type.value, + icon_info=icon_info, + input_fields=json.dumps(input_fields) if input_fields else None, + created_by="account-1", + ) + + +def _workflow(*, graph: dict | None = None) -> Workflow: + return Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="snippet-1", + type=WorkflowType.WORKFLOW, + version="draft", + graph=json.dumps(graph or {"nodes": [], "edges": []}), + _features="{}", + created_by="account-1", + ) + @pytest.mark.parametrize( ("version", "expected"), @@ -29,18 +91,14 @@ def test_check_version_compatibility_returns_pending_for_older_major() -> None: assert _check_version_compatibility("0.0.9") == ImportStatus.COMPLETED_WITH_WARNINGS -def test_import_snippet_rejects_invalid_mode(): - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_rejects_invalid_mode(service: SnippetDslService): with pytest.raises(ValueError, match="Invalid import_mode"): - service.import_snippet(account=SimpleNamespace(current_tenant_id="tenant-1"), import_mode="bad-mode") + service.import_snippet(account=_account(), import_mode="bad-mode") -def test_import_snippet_requires_yaml_content(): - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_requires_yaml_content(service: SnippetDslService): result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, ) @@ -48,11 +106,9 @@ def test_import_snippet_requires_yaml_content(): assert result.error == "yaml_content is required when import_mode is yaml-content" -def test_import_snippet_requires_yaml_url() -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_requires_yaml_url(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, ) @@ -60,11 +116,9 @@ def test_import_snippet_requires_yaml_url() -> None: assert result.error == "yaml_url is required when import_mode is yaml-url" -def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_rejects_invalid_yaml_url_scheme(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="file:///tmp/snippet.yaml", ) @@ -73,15 +127,16 @@ def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None: assert result.error == "Invalid URL scheme, only http and https are allowed" -def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_returns_failed_when_yaml_url_fetch_fails( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", Mock(return_value=SimpleNamespace(status_code=404, text="not found")), ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -90,8 +145,9 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch: py assert result.error == "Failed to fetch YAML from URL: 404" -def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_oversized_yaml_url_content( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", @@ -99,7 +155,7 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -108,8 +164,9 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M assert "YAML content size exceeds maximum limit" in result.error -def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", @@ -117,7 +174,7 @@ def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypat ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -127,16 +184,15 @@ def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypat def test_import_snippet_returns_decode_error_for_invalid_yaml_url_bytes( - monkeypatch: pytest.MonkeyPatch, + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch ) -> None: - service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", Mock(return_value=SimpleNamespace(status_code=200, content=b"\xff")), ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -145,15 +201,16 @@ def test_import_snippet_returns_decode_error_for_invalid_yaml_url_bytes( assert "utf-8" in result.error -def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_returns_failed_when_yaml_url_fetch_raises( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", Mock(side_effect=RuntimeError("network down")), ) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_URL.value, yaml_url="https://example.com/snippet.yaml", ) @@ -162,12 +219,13 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: p assert result.error == "Failed to fetch YAML from URL: network down" -def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.MonkeyPatch) -> None: - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_oversized_yaml_content( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1) result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="é", ) @@ -188,11 +246,9 @@ def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.Monke ("version: 0.1.0\nkind: snippet\n", "Missing snippet data in YAML content"), ], ) -def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error) -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_rejects_invalid_yaml_shapes(service: SnippetDslService, yaml_content, expected_error) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, ) @@ -201,11 +257,9 @@ def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error assert expected_error in result.error -def test_import_snippet_returns_failed_for_invalid_version_type() -> None: - service = SnippetDslService(session=SimpleNamespace(rollback=Mock())) - +def test_import_snippet_returns_failed_for_invalid_version_type(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="version: 1\nkind: snippet\nsnippet:\n name: Bad Version\n", ) @@ -214,11 +268,9 @@ def test_import_snippet_returns_failed_for_invalid_version_type() -> None: assert "Invalid version type" in result.error -def test_import_snippet_returns_failed_for_invalid_yaml_syntax() -> None: - service = SnippetDslService(session=SimpleNamespace()) - +def test_import_snippet_returns_failed_for_invalid_yaml_syntax(service: SnippetDslService) -> None: result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="kind: snippet\nsnippet: [", ) @@ -227,8 +279,7 @@ def test_import_snippet_returns_failed_for_invalid_yaml_syntax() -> None: assert result.error.startswith("Invalid YAML format:") -def test_import_snippet_rejects_forbidden_nodes(): - service = SnippetDslService(session=SimpleNamespace()) +def test_import_snippet_rejects_forbidden_nodes(service: SnippetDslService): yaml_content = """ version: 0.3.0 kind: snippet @@ -244,7 +295,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, ) @@ -253,8 +304,7 @@ workflow: assert result.error == "Snippet cannot contain the following node types: start" -def test_import_snippet_stores_pending_data_for_newer_dsl(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) +def test_import_snippet_stores_pending_data_for_newer_dsl(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): setex = Mock() monkeypatch.setattr("services.snippet_dsl_service.redis_client.setex", setex) yaml_content = """ @@ -269,7 +319,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, name="Override", @@ -286,8 +336,7 @@ workflow: assert pending.description == "Override description" -def test_import_snippet_returns_failed_when_update_target_missing(): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) +def test_import_snippet_returns_failed_when_update_target_missing(service: SnippetDslService): yaml_content = """ version: 0.1.0 kind: snippet @@ -300,7 +349,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, snippet_id="missing-snippet", @@ -310,9 +359,10 @@ workflow: assert result.error == "Snippet not found" -def test_import_snippet_passes_dependencies_to_create_or_update(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) - snippet = SimpleNamespace(id="snippet-1") +def test_import_snippet_passes_dependencies_to_create_or_update( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): + snippet = _snippet() create_or_update = Mock(return_value=snippet) monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update) yaml_content = """ @@ -331,7 +381,7 @@ workflow: """ result = service.import_snippet( - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content=yaml_content, ) @@ -342,50 +392,50 @@ workflow: assert dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" -def test_import_snippet_rolls_back_when_create_or_update_raises(monkeypatch: pytest.MonkeyPatch): - session = SimpleNamespace(scalar=Mock(return_value=None), rollback=Mock()) - service = SnippetDslService(session=session) +def test_import_snippet_rolls_back_when_create_or_update_raises( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + rollback_events: list[str] = [] + event.listen(sqlite_session, "after_rollback", lambda _session: rollback_events.append("rollback")) + sqlite_session.begin() monkeypatch.setattr(service, "_create_or_update_snippet", Mock(side_effect=RuntimeError("boom"))) result = service.import_snippet( - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), import_mode=ImportMode.YAML_CONTENT.value, yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", ) assert result.status == ImportStatus.FAILED assert result.error == "boom" - session.rollback.assert_called_once() + assert rollback_events == ["rollback"] -def test_confirm_import_returns_failed_when_pending_data_missing(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_confirm_import_returns_failed_when_pending_data_missing( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=None)) - result = service.confirm_import( - import_id="missing", account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - ) + result = service.confirm_import(import_id="missing", account=_account()) assert result.status == ImportStatus.FAILED assert result.error == "Import information expired or does not exist" -def test_confirm_import_returns_failed_for_invalid_pending_payload(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_confirm_import_returns_failed_for_invalid_pending_payload( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=object())) - result = service.confirm_import( - import_id="bad", account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - ) + result = service.confirm_import(import_id="bad", account=_account()) assert result.status == ImportStatus.FAILED assert result.error == "Invalid import information" -def test_confirm_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) - account = SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - snippet = SimpleNamespace(id="snippet-new") +def test_confirm_import_is_scoped_to_its_owner(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): + account = _account() + snippet = _snippet(snippet_id="snippet-new") yaml_content = """ version: 9.0.0 kind: snippet @@ -417,8 +467,8 @@ workflow: monkeypatch.setattr("services.snippet_dsl_service.redis_client.delete", redis_delete) for other_account in ( - SimpleNamespace(id="account-1", current_tenant_id="tenant-2"), - SimpleNamespace(id="account-2", current_tenant_id="tenant-1"), + _account(tenant_id="tenant-2"), + _account(account_id="account-2"), ): assert service.confirm_import(import_id="import-1", account=other_account).status == ImportStatus.FAILED @@ -437,8 +487,9 @@ workflow: redis_delete.assert_called_once_with(redis_key) -def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_confirm_import_returns_failed_for_non_mapping_yaml( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): pending = SnippetPendingData( import_mode="yaml-content", yaml_content="- item", @@ -446,17 +497,17 @@ def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch: pytest. ) monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json())) - result = service.confirm_import( - import_id="import-1", account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1") - ) + result = service.confirm_import(import_id="import-1", account=_account()) assert result.status == ImportStatus.FAILED assert result.error == "Invalid YAML format: expected a dictionary" -def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch: pytest.MonkeyPatch): - session = SimpleNamespace(scalar=Mock(return_value=None), rollback=Mock()) - service = SnippetDslService(session=session) +def test_confirm_import_returns_failed_when_create_or_update_raises( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + rollback_events: list[str] = [] + event.listen(sqlite_session, "after_rollback", lambda _session: rollback_events.append("rollback")) pending = SnippetPendingData( import_mode="yaml-content", yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", @@ -467,29 +518,29 @@ def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch: result = service.confirm_import( import_id="import-1", - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert result.status == ImportStatus.FAILED assert result.error == "boom" - session.rollback.assert_called_once() + assert rollback_events == ["rollback"] -def test_check_dependencies_returns_empty_without_draft_workflow(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) +def test_check_dependencies_returns_empty_without_draft_workflow( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr( "services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), ) - result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1")) + result = service.check_dependencies(_snippet()) assert result.leaked_dependencies == [] -def test_check_dependencies_returns_generated_dependencies(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) - workflow = SimpleNamespace(graph_dict={"nodes": []}) +def test_check_dependencies_returns_generated_dependencies(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): + workflow = _workflow() leaked_dependencies = [ { "type": "marketplace", @@ -506,29 +557,25 @@ def test_check_dependencies_returns_generated_dependencies(monkeypatch: pytest.M Mock(return_value=leaked_dependencies), ) - result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1")) + result = service.check_dependencies(_snippet()) assert result.leaked_dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" -def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(monkeypatch: pytest.MonkeyPatch): - snippet = SimpleNamespace( - id="snippet-1", - tenant_id="tenant-1", +def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + snippet = _snippet( name="Old", description="Old", - type="node", icon_info=None, - input_fields=None, - updated_by=None, - updated_at=None, ) - session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock()) - service = SnippetDslService(session=session) - draft_workflow = SimpleNamespace(unique_hash="hash-1") + sqlite_session.add(snippet) + sqlite_session.commit() + draft_workflow = _workflow() snippet_service = SimpleNamespace( get_draft_workflow=Mock(return_value=draft_workflow), - sync_draft_workflow=Mock(), + sync_draft_workflow=Mock(return_value=draft_workflow), ) monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service) monkeypatch.setattr( @@ -557,7 +604,7 @@ def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(mo }, "workflow": {"graph": {"nodes": [], "edges": []}}, }, - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert result is snippet @@ -565,7 +612,10 @@ def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(mo assert snippet.type == "node" assert snippet.icon_info == {"icon": "x"} snippet_service.sync_draft_workflow.assert_called_once() - session.commit.assert_called_once() + assert not sqlite_session.in_transaction() + persisted = sqlite_session.get(CustomizedSnippet, snippet.id) + assert persisted is not None + assert persisted.name == "New" retire_unowned.assert_called_once_with( tenant_id="tenant-1", agent_ids={"retired-agent"}, @@ -573,10 +623,13 @@ def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(mo ) -def test_create_or_update_snippet_creates_new_snippet_and_flushes(monkeypatch: pytest.MonkeyPatch): - session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock()) - service = SnippetDslService(session=session) - snippet_service = SimpleNamespace(get_draft_workflow=Mock(return_value=None), sync_draft_workflow=Mock()) +def test_create_or_update_snippet_creates_new_snippet_and_flushes( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +): + snippet_service = SimpleNamespace( + get_draft_workflow=Mock(return_value=None), + sync_draft_workflow=Mock(return_value=_workflow()), + ) monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service) monkeypatch.setattr( "services.snippet_dsl_service.WorkflowAgentPublishService.sync_agent_bindings_for_draft", @@ -598,41 +651,33 @@ def test_create_or_update_snippet_creates_new_snippet_and_flushes(monkeypatch: p }, "workflow": {"graph": {"nodes": [], "edges": []}}, }, - account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + account=_account(), ) assert result.name == "New Snippet" assert result.type == "group" - session.add.assert_called_once_with(result) - session.flush.assert_called_once() + assert sqlite_session.get(CustomizedSnippet, result.id) is result snippet_service.sync_draft_workflow.assert_called_once() - session.commit.assert_called_once() + assert not sqlite_session.in_transaction() -def test_export_snippet_dsl_raises_without_draft_workflow(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) +def test_export_snippet_dsl_raises_without_draft_workflow(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), ) with pytest.raises(ValueError, match="Missing draft workflow"): - service.export_snippet_dsl(SimpleNamespace()) + service.export_snippet_dsl(_snippet()) -def test_export_snippet_dsl_returns_yaml(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) - workflow = SimpleNamespace( - to_dict=Mock(return_value={"graph": {"nodes": []}}), - graph_dict={"nodes": []}, - ) - snippet = SimpleNamespace( - tenant_id="tenant-1", +def test_export_snippet_dsl_returns_yaml(service: SnippetDslService, monkeypatch: pytest.MonkeyPatch): + workflow = _workflow() + snippet = _snippet( name="Exported", description=None, - type="node", icon_info=None, - input_fields_list=[{"variable": "query"}], + input_fields=[{"variable": "query"}], ) monkeypatch.setattr( "services.snippet_dsl_service.SnippetService", @@ -650,20 +695,11 @@ def test_export_snippet_dsl_returns_yaml(monkeypatch: pytest.MonkeyPatch): assert "input_fields:" in result -def test_export_snippet_dsl_uses_requested_published_workflow(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) - workflow = SimpleNamespace( - to_dict=Mock(return_value={"graph": {"nodes": []}}), - graph_dict={"nodes": []}, - ) - snippet = SimpleNamespace( - tenant_id="tenant-1", - name="Exported", - description=None, - type="node", - icon_info=None, - input_fields_list=[], - ) +def test_export_snippet_dsl_uses_requested_published_workflow( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): + workflow = _workflow(graph={"nodes": [], "edges": []}) + snippet = _snippet(name="Exported") get_published_workflow_by_id = Mock(return_value=workflow) get_draft_workflow = Mock() monkeypatch.setattr( @@ -684,8 +720,9 @@ def test_export_snippet_dsl_uses_requested_published_workflow(monkeypatch: pytes get_draft_workflow.assert_not_called() -def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): workflow_dict = { "graph": { "nodes": [ @@ -718,10 +755,7 @@ def test_append_workflow_export_data_filters_credentials_and_extracts_dependenci "environment_variables": [{"name": "SECRET"}], "conversation_variables": [{"name": "memory"}], } - workflow = SimpleNamespace( - to_dict=Mock(return_value=workflow_dict), - graph_dict=workflow_dict["graph"], - ) + workflow = _workflow(graph=workflow_dict["graph"]) monkeypatch.setattr( "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", Mock(return_value=[]), @@ -730,7 +764,7 @@ def test_append_workflow_export_data_filters_credentials_and_extracts_dependenci service._append_workflow_export_data( export_data=export_data, - snippet=SimpleNamespace(tenant_id="tenant-1"), + snippet=_snippet(), workflow=workflow, include_secret=False, ) @@ -742,8 +776,9 @@ def test_append_workflow_export_data_filters_credentials_and_extracts_dependenci assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0] -def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch): - service = SnippetDslService(session=SimpleNamespace()) +def test_append_workflow_export_data_rewrites_knowledge_dataset_ids( + service: SnippetDslService, monkeypatch: pytest.MonkeyPatch +): workflow_dict = { "graph": { "nodes": [ @@ -756,7 +791,7 @@ def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch: ] }, } - workflow = SimpleNamespace(to_dict=Mock(return_value=workflow_dict), graph_dict=workflow_dict["graph"]) + workflow = _workflow(graph=workflow_dict["graph"]) monkeypatch.setattr( service, "_encrypt_dataset_id", @@ -770,7 +805,7 @@ def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch: service._append_workflow_export_data( export_data=export_data, - snippet=SimpleNamespace(tenant_id="tenant-1"), + snippet=_snippet(), workflow=workflow, include_secret=True, ) diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index caac531bdbc..10c1622ab6e 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -940,15 +940,14 @@ def test_delete_draft_variable_files_removes_storage_objects( def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch: pytest.MonkeyPatch) -> None: - from configs import dify_config + from tests.unit_tests.config_override import apply_config_overrides snippet = _snippet() archive_storage = SimpleNamespace( list_objects=Mock(return_value=["tenant-1/app_id=snippet-1/run.json"]), delete_object=Mock(), ) - monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(dify_config, "ARCHIVE_STORAGE_ENABLED", True) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, ARCHIVE_STORAGE_ENABLED=True) monkeypatch.setattr("libs.archive_storage.get_archive_storage", Mock(return_value=archive_storage)) SnippetService._delete_archived_workflow_run_files(snippet=snippet) diff --git a/api/tests/unit_tests/services/test_step_by_step_tour_service.py b/api/tests/unit_tests/services/test_step_by_step_tour_service.py index 94203183ef6..40017bb7798 100644 --- a/api/tests/unit_tests/services/test_step_by_step_tour_service.py +++ b/api/tests/unit_tests/services/test_step_by_step_tour_service.py @@ -1,227 +1,213 @@ from __future__ import annotations -from datetime import UTC, datetime +from collections.abc import Callable +from dataclasses import replace +from datetime import datetime +from unittest.mock import Mock import pytest -from sqlalchemy import event, select -from sqlalchemy.orm import Session, sessionmaker -from enums import DeploymentEdition -from models.account import Account, AccountStatus -from models.onboarding import AccountStepByStepTourState -from services import step_by_step_tour_service as service_module +from machinery.context import RequestContext +from services.account_ports import AccountRepository +from services.entities.account_entities import AccountSnapshot +from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult, StepByStepTourState from services.step_by_step_tour_service import StepByStepTourService -def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account: - account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE) - account.id = "account-1" - account.initialized_at = initialized_at - account.created_at = created_at or datetime(2026, 6, 28) - return account - - -def _state() -> AccountStepByStepTourState: - state = AccountStepByStepTourState(account_id="account-1") - state.updated_at = datetime(2026, 6, 28, tzinfo=UTC) - return state - - -def _persist_state(session: Session, state: AccountStepByStepTourState) -> None: - session.add(state) - session.commit() - - -def _load_state(session: Session) -> AccountStepByStepTourState | None: - return session.scalar( - select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == "account-1") +def _context(*, workspace_id: str | None = "workspace-1") -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id=workspace_id, ) -def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None: - monkeypatch.setattr(service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled) - monkeypatch.setattr(service_module.dify_config, "STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT", rollout_started_at) +class StateRepositoryStub: + def __init__(self, state: StepByStepTourState | None = None) -> None: + self.state = state + self.get_account_ids: list[str] = [] + self.initialize_calls: list[tuple[str, str]] = [] + self.mutation_account_ids: list[str] = [] + + def get(self, account_id: str) -> StepByStepTourState | None: + self.get_account_ids.append(account_id) + return self.state + + def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: + self.initialize_calls.append((account_id, first_workspace_id)) + if self.state is None: + self.state = StepByStepTourState(account_id=account_id, first_workspace_id=first_workspace_id) + elif self.state.first_workspace_id is None: + self.state = replace(self.state, first_workspace_id=first_workspace_id) + return self.state + + def mutate( + self, + account_id: str, + mutation: Callable[[StepByStepTourState], StepByStepTourState], + ) -> StepByStepTourState: + self.mutation_account_ids.append(account_id) + if self.state is None: + self.state = StepByStepTourState(account_id=account_id) + self.state = mutation(self.state) + return self.state -def test_get_state_creates_state_and_records_first_workspace_for_eligible_account( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - - result = StepByStepTourService.get_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - session=sqlite_session, +def _account(*, started_at: datetime = datetime(2026, 6, 28)) -> AccountSnapshot: + return AccountSnapshot( + id="account-1", + name="Account", + email="account@example.com", + avatar=None, + is_password_set=False, + interface_language="en-US", + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status="active", + initialized_at=started_at, + created_at=started_at, ) - assert result["first_workspace_id"] == "workspace-1" - assert result["completed_task_ids"] == [] - with sqlite_session_factory() as observer: - persisted = _load_state(observer) - assert persisted is not None - assert persisted.account_id == "account-1" - assert persisted.first_workspace_id == "workspace-1" + +def _accounts(account: AccountSnapshot | None) -> Mock: + accounts = Mock(spec=AccountRepository) + accounts.get.return_value = account + return accounts -def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None: - _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) - - result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28))) - - assert result is True - - -def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - - result = StepByStepTourService.get_state( - account=_account(initialized_at=datetime(2026, 5, 31)), - current_tenant_id="workspace-1", - session=sqlite_session, +def _service( + *, + states: StateRepositoryStub, + account: AccountSnapshot | None = None, + enabled: bool = True, + rollout_started_at: datetime | None = datetime(2026, 6, 1), +) -> StepByStepTourService: + return StepByStepTourService( + accounts=_accounts(account or _account()), + states=states, + enabled=enabled, + rollout_started_at=rollout_started_at, ) - assert result == { - "first_workspace_id": None, - "skipped": False, - "completed_task_ids": [], - "manually_enabled_workspace_ids": [], - "manually_disabled_workspace_ids": [], - "updated_at": None, - } - with sqlite_session_factory() as observer: - assert _load_state(observer) is None + +def test_get_state_creates_state_and_records_first_workspace_for_eligible_account() -> None: + states = StateRepositoryStub() + + result = _service(states=states).get_state(_context()) + + assert result.first_workspace_id == "workspace-1" + assert states.get_account_ids == [] + assert states.initialize_calls == [("account-1", "workspace-1")] + assert states.mutation_account_ids == [] -def test_patch_state_persists_even_when_account_is_not_eligible( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) +def test_get_state_returns_existing_state_without_rewriting_first_workspace() -> None: + state = StepByStepTourState(account_id="account-1", first_workspace_id="workspace-original") + states = StateRepositoryStub(state) - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-2", - patch={"action": "enable_current_workspace"}, - session=sqlite_session, + result = _service(states=states).get_state(_context(workspace_id="workspace-current")) + + assert result.first_workspace_id == "workspace-original" + assert states.initialize_calls == [("account-1", "workspace-current")] + assert states.mutation_account_ids == [] + + +def test_get_state_does_not_create_state_for_ineligible_account() -> None: + states = StateRepositoryStub() + service = _service(states=states, account=_account(started_at=datetime(2026, 5, 31))) + + result = service.get_state(_context()) + + assert result == StepByStepTourResult() + assert states.get_account_ids == ["account-1"] + assert states.mutation_account_ids == [] + + +def test_get_state_does_not_create_state_when_tour_is_disabled() -> None: + states = StateRepositoryStub() + + result = _service(states=states, enabled=False).get_state(_context()) + + assert result == StepByStepTourResult() + assert states.get_account_ids == ["account-1"] + + +def test_patch_state_persists_even_when_tour_is_disabled() -> None: + states = StateRepositoryStub() + service = _service(states=states, enabled=False) + + result = service.patch_state(_context(workspace_id="workspace-2"), StepByStepTourPatch("enable_current_workspace")) + + assert result.manually_enabled_workspace_ids == ("workspace-2",) + assert states.mutation_account_ids == ["account-1"] + + +def test_patch_state_skip_removes_current_workspace_enable() -> None: + states = StateRepositoryStub( + StepByStepTourState( + account_id="account-1", + manually_enabled_workspace_ids=("workspace-1", "workspace-2"), + ) ) - assert result["skipped"] is False - assert result["manually_enabled_workspace_ids"] == ["workspace-2"] - assert result["manually_disabled_workspace_ids"] == [] - with sqlite_session_factory() as observer: - persisted = _load_state(observer) - assert persisted is not None - assert persisted.manually_enabled_workspace_ids == ["workspace-2"] + result = _service(states=states).patch_state(_context(), StepByStepTourPatch("skip")) + + assert result.skipped is True + assert result.manually_enabled_workspace_ids == ("workspace-2",) -def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - state = _state() - state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"] - _persist_state(sqlite_session, state) - - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "skip"}, - session=sqlite_session, +def test_patch_state_disable_moves_current_workspace_to_disabled() -> None: + states = StateRepositoryStub( + StepByStepTourState( + account_id="account-1", + manually_enabled_workspace_ids=("workspace-1", "workspace-2"), + ) ) - assert result["skipped"] is True - assert result["manually_enabled_workspace_ids"] == ["workspace-2"] - assert result["manually_disabled_workspace_ids"] == [] - assert _load_state(sqlite_session) is state - - -def test_patch_state_disable_action_moves_current_workspace_to_disabled( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - state = _state() - state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"] - _persist_state(sqlite_session, state) - - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "disable_current_workspace"}, - session=sqlite_session, + result = _service(states=states).patch_state( + _context(), + StepByStepTourPatch("disable_current_workspace"), ) - assert result["manually_enabled_workspace_ids"] == ["workspace-2"] - assert result["manually_disabled_workspace_ids"] == ["workspace-1"] - assert _load_state(sqlite_session) is state + assert result.manually_enabled_workspace_ids == ("workspace-2",) + assert result.manually_disabled_workspace_ids == ("workspace-1",) -def test_patch_state_complete_and_uncomplete_task( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - state = _state() - state.completed_task_ids = ["home"] - _persist_state(sqlite_session, state) +def test_patch_state_complete_and_uncomplete_task() -> None: + states = StateRepositoryStub(StepByStepTourState(account_id="account-1", completed_task_ids=("home",))) + service = _service(states=states) - StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "complete_task", "task_id": "studio"}, - session=sqlite_session, - ) - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-1", - patch={"action": "uncomplete_task", "task_id": "home"}, - session=sqlite_session, + service.patch_state(_context(), StepByStepTourPatch("complete_task", "studio")) + result = service.patch_state(_context(), StepByStepTourPatch("uncomplete_task", "home")) + + assert result.completed_task_ids == ("studio",) + + +def test_rejects_unsupported_task_id() -> None: + with pytest.raises(ValueError, match="Unsupported task_id"): + StepByStepTourService._require_task_id("unknown") + + +def test_rejects_missing_workspace_before_using_state_repository() -> None: + states = StateRepositoryStub() + + with pytest.raises(RuntimeError, match="did not resolve an active workspace"): + _service(states=states).patch_state(_context(workspace_id=None), StepByStepTourPatch("skip")) + + assert states.mutation_account_ids == [] + + +def test_get_state_rejects_unknown_admitted_account() -> None: + states = StateRepositoryStub() + service = StepByStepTourService( + accounts=_accounts(None), + states=states, + enabled=True, + rollout_started_at=datetime(2026, 6, 1), ) - assert result["completed_task_ids"] == ["studio"] - - -def test_patch_state_recovers_when_concurrent_request_created_state( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], -) -> None: - _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1)) - existing_state = _state() - existing_state.manually_enabled_workspace_ids = ["workspace-1"] - lifecycle_events: list[str] = [] - - @event.listens_for(sqlite_session, "before_flush", once=True) - def add_conflicting_pending_state(session: Session, _flush_context, _instances) -> None: - lifecycle_events.append("before_flush") - session.add(AccountStepByStepTourState(account_id="account-1")) - - @event.listens_for(sqlite_session, "after_soft_rollback", once=True) - def persist_winning_request(_session: Session, _previous_transaction) -> None: - lifecycle_events.append("after_soft_rollback") - with sqlite_session_factory() as winner: - winner.add(existing_state) - winner.commit() - - result = StepByStepTourService.patch_state( - account=_account(initialized_at=datetime(2026, 6, 28)), - current_tenant_id="workspace-2", - patch={"action": "enable_current_workspace"}, - session=sqlite_session, - ) - - assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"] - assert lifecycle_events == ["before_flush", "after_soft_rollback"] - with sqlite_session_factory() as observer: - persisted = _load_state(observer) - assert persisted is not None - assert persisted.manually_enabled_workspace_ids == ["workspace-1", "workspace-2"] + with pytest.raises(RuntimeError, match="unknown account"): + service.get_state(_context()) diff --git a/api/tests/unit_tests/services/test_telemetry_service.py b/api/tests/unit_tests/services/test_telemetry_service.py index 8b1f9df0558..8b11e3a2ff7 100644 --- a/api/tests/unit_tests/services/test_telemetry_service.py +++ b/api/tests/unit_tests/services/test_telemetry_service.py @@ -58,11 +58,16 @@ def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_e @pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) -def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch): +def test_report_install_marks_reported_at( + sqlite_session: Session, + telemetry_enabled, + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], +): setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758") sqlite_session.add(setup) sqlite_session.commit() - monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version") + config_overrides(project=telemetry_service.dify_config.project.model_copy(update={"version": "running-version"})) sent_payloads: list[dict[str, str | int]] = [] @@ -190,12 +195,15 @@ def test_report_install_does_not_use_fallback_endpoint_after_http_error( @pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) def test_report_heartbeat_retries_pending_install_before_heartbeat( - sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch + sqlite_session: Session, + telemetry_enabled, + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], ): setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758") sqlite_session.add(setup) sqlite_session.commit() - monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version") + config_overrides(project=telemetry_service.dify_config.project.model_copy(update={"version": "running-version"})) sent_payloads: list[dict[str, str | int]] = [] diff --git a/api/tests/unit_tests/services/test_turnstile_service.py b/api/tests/unit_tests/services/test_turnstile_service.py index 5c2173b5eb8..e812e30fdbc 100644 --- a/api/tests/unit_tests/services/test_turnstile_service.py +++ b/api/tests/unit_tests/services/test_turnstile_service.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from unittest.mock import MagicMock import httpx @@ -13,9 +14,8 @@ from services.turnstile_service import ( @pytest.fixture(autouse=True) -def configure_turnstile(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_SECRET_KEY", SecretStr("test-secret")) - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_ALLOWED_HOSTNAMES", "dify.dev") +def configure_turnstile(config_overrides: Callable[..., None]) -> None: + config_overrides(TURNSTILE_SECRET_KEY=SecretStr("test-secret"), TURNSTILE_ALLOWED_HOSTNAMES="dify.dev") def mock_response(monkeypatch: pytest.MonkeyPatch, *, status_code: int = 200, payload: object) -> MagicMock: @@ -125,12 +125,11 @@ def test_verify_maps_timeout_to_upstream_error(monkeypatch: pytest.MonkeyPatch) [(None, "dify.dev"), (SecretStr("test-secret"), "")], ) def test_verify_fails_closed_when_cloud_configuration_is_missing( - monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], secret: SecretStr | None, allowed_hostnames: str, ) -> None: - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_SECRET_KEY", secret) - monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_ALLOWED_HOSTNAMES", allowed_hostnames) + config_overrides(TURNSTILE_SECRET_KEY=secret, TURNSTILE_ALLOWED_HOSTNAMES=allowed_hostnames) with pytest.raises(TurnstileUpstreamError): TurnstileService.verify(token="verified-token", remote_ip=None) diff --git a/api/tests/unit_tests/services/test_variable_truncator_additional.py b/api/tests/unit_tests/services/test_variable_truncator_additional.py index 50644d42946..7e08ca75474 100644 --- a/api/tests/unit_tests/services/test_variable_truncator_additional.py +++ b/api/tests/unit_tests/services/test_variable_truncator_additional.py @@ -7,13 +7,17 @@ from graphon.variables.segments import IntegerSegment, ObjectSegment, StringSegm from graphon.variables.types import SegmentType from services import variable_truncator as truncator_module from services.variable_truncator import VariableTruncator +from tests.unit_tests.config_override import apply_config_overrides class TestVariableTruncatorAdditionalBehavior: def test_default_should_use_dify_config_limits(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE", 111) - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH", 7) - monkeypatch.setattr(truncator_module.dify_config, "WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH", 33) + apply_config_overrides( + monkeypatch, + WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE=111, + WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH=7, + WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH=33, + ) truncator = VariableTruncator.default() diff --git a/api/tests/unit_tests/services/test_webhook_service_additional.py b/api/tests/unit_tests/services/test_webhook_service_additional.py index 44c13149088..9bf7f9d50b6 100644 --- a/api/tests/unit_tests/services/test_webhook_service_additional.py +++ b/api/tests/unit_tests/services/test_webhook_service_additional.py @@ -62,7 +62,9 @@ class TestWebhookServiceExtractionFallbacks: flask_app: Flask, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(service_module.dify_config, "WEBHOOK_REQUEST_BODY_MAX_SIZE", 1) + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, WEBHOOK_REQUEST_BODY_MAX_SIZE=1) with flask_app.test_request_context("/webhook", method="POST", data="ab"): with pytest.raises(RequestEntityTooLarge): diff --git a/api/tests/unit_tests/services/test_workflow_collaboration_service.py b/api/tests/unit_tests/services/test_workflow_collaboration_service.py index 3cf1f3c0a21..943677502f7 100644 --- a/api/tests/unit_tests/services/test_workflow_collaboration_service.py +++ b/api/tests/unit_tests/services/test_workflow_collaboration_service.py @@ -14,6 +14,7 @@ from models.base import TypeBase from models.model import App, AppMode, IconType from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository from services.workflow_collaboration_service import SYNC_REQUEST_TIMEOUT_SECONDS, WorkflowCollaborationService +from tests.unit_tests.config_override import config_overrides_context @pytest.fixture @@ -71,7 +72,7 @@ class TestWorkflowCollaborationService: db_session.commit() with ( - patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "services.workflow_collaboration_service.RBACService.CheckAccess.check", return_value=True ) as check_access, @@ -140,7 +141,7 @@ class TestWorkflowCollaborationService: db_session.commit() with ( - patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch( "services.workflow_collaboration_service.RBACService.CheckAccess.check", return_value=False ) as check_access, @@ -195,7 +196,7 @@ class TestWorkflowCollaborationService: ) db_session.commit() - with patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", False): + with config_overrides_context(RBAC_ENABLED=False): result = collaboration_service._can_access_workflow("wf-1", "tenant-1", "user-1", session=db_session) assert result is True @@ -216,7 +217,7 @@ class TestWorkflowCollaborationService: db_session.commit() with ( - patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + config_overrides_context(RBAC_ENABLED=True), patch("services.workflow_collaboration_service.RBACService.CheckAccess.check") as check_access, ): result = collaboration_service._can_access_workflow("wf-1", "tenant-1", "owner-1", session=db_session) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 16357514227..5d4e3d92ced 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -11,6 +11,7 @@ This test suite covers: import json import uuid +from collections.abc import Callable from datetime import datetime, timedelta from types import SimpleNamespace from typing import Any, cast @@ -215,6 +216,10 @@ class TestWorkflowAssociatedDataFactory: @pytest.mark.usefixtures("sqlite_session") class TestWorkflowService: + @pytest.fixture(autouse=True) + def _community_edition(self, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + """ Comprehensive unit tests for WorkflowService methods. @@ -1272,7 +1277,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch( "services.workflow_service.register_new_agent_beta_workflow_publish_after_commit" ) as register_workflow_publish, @@ -1306,7 +1310,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch( "services.agent.workflow_publish_service.WorkflowAgentPublishService.copy_agent_node_bindings_to_published", return_value=True, @@ -1346,10 +1349,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch( - "services.workflow_service.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), ): first = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) second = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) @@ -1376,10 +1375,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch( - "services.workflow_service.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), ): published = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) sqlite_session.flush() @@ -1415,10 +1410,6 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch( - "services.workflow_service.dify_config.DEPLOYMENT_EDITION", - DeploymentEdition.COMMUNITY, - ), ): workflow = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) published.append(workflow) @@ -1485,7 +1476,13 @@ class TestWorkflowService: ): workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) - def test_publish_workflow_trigger_limit_exceeded(self, workflow_service: WorkflowService, sqlite_session: Session): + def test_publish_workflow_trigger_limit_exceeded( + self, + workflow_service: WorkflowService, + sqlite_session: Session, + config_overrides: Callable[..., None], + ): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) """ Test publish_workflow raises error when trigger node limit exceeded in SANDBOX plan. @@ -1511,7 +1508,6 @@ class TestWorkflowService: sqlite_session.commit() with ( - patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.workflow_service.BillingService") as MockBillingService, ): MockBillingService.get_info.return_value = {"subscription": {"plan": "sandbox"}} diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index c6926a310ed..bcaabd7b815 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -296,7 +296,9 @@ class TestGetOauthClientSchema: monkeypatch.setattr(BuiltinToolManageService, "is_oauth_custom_client_enabled", MagicMock(return_value=True)) monkeypatch.setattr(BuiltinToolManageService, "is_oauth_system_client_exists", MagicMock(return_value=False)) monkeypatch.setattr(BuiltinToolManageService, "get_custom_oauth_client_params", MagicMock(return_value={})) - monkeypatch.setattr(service_module.dify_config, "CONSOLE_API_URL", "https://api.example.com") + from tests.unit_tests.config_override import apply_config_overrides + + apply_config_overrides(monkeypatch, CONSOLE_API_URL="https://api.example.com") result = BuiltinToolManageService.get_builtin_tool_provider_oauth_client_schema("t", "google") diff --git a/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py b/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py index 706ea415e2a..3cc8739623d 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_converter_additional.py @@ -2,7 +2,7 @@ from __future__ import annotations import json from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock, call import pytest @@ -45,15 +45,36 @@ def converter() -> WorkflowConverter: def _app_model(**kwargs: Any) -> App: - return cast(App, SimpleNamespace(**kwargs)) + defaults: dict[str, Any] = { + "id": "app-1", + "tenant_id": "tenant-1", + "name": "Source App", + "description": "", + "mode": AppMode.CHAT, + "enable_site": True, + "enable_api": True, + "max_active_requests": 0, + } + defaults.update(kwargs) + return App(**defaults) def _account(**kwargs: Any) -> Account: - return cast(Account, SimpleNamespace(**kwargs)) + account_id = kwargs.pop("id", "account-1") + account = Account( + name=kwargs.pop("name", "Converter user"), + email=kwargs.pop("email", "user@example.com"), + **kwargs, + ) + account.id = account_id + return account def _app_model_config(**kwargs: Any) -> AppModelConfig: - return cast(AppModelConfig, SimpleNamespace(**kwargs)) + config_id = kwargs.pop("id", "config-1") + config = AppModelConfig(app_id=kwargs.pop("app_id", "app-1"), **kwargs) + config.id = config_id + return config def _build_start_graph() -> dict[str, Any]: @@ -94,10 +115,7 @@ def test__convert_to_start_node(default_variables: list[VariableEntity]) -> None def test__convert_to_http_request_node_for_chatbot( default_variables: list[VariableEntity], unbound_session: Session ) -> None: - app_model = MagicMock() - app_model.id = "app_id" - app_model.tenant_id = "tenant_id" - app_model.mode = AppMode.CHAT + app_model = _app_model(id="app_id", tenant_id="tenant_id", mode=AppMode.CHAT) extension = APIBasedExtension( tenant_id="tenant_id", @@ -139,10 +157,7 @@ def test__convert_to_http_request_node_for_chatbot( def test__convert_to_http_request_node_for_workflow_app( default_variables: list[VariableEntity], unbound_session: Session ) -> None: - app_model = MagicMock() - app_model.id = "app_id" - app_model.tenant_id = "tenant_id" - app_model.mode = AppMode.WORKFLOW + app_model = _app_model(id="app_id", tenant_id="tenant_id", mode=AppMode.WORKFLOW) extension = APIBasedExtension( tenant_id="tenant_id", @@ -593,7 +608,7 @@ def test_convert_app_model_config_to_workflow_should_build_workflow_mode_with_en def test_convert_to_app_config_should_route_to_correct_manager( converter: WorkflowConverter, monkeypatch: pytest.MonkeyPatch, - unbound_session: Session, + sqlite_session: Session, ) -> None: agent_result = SimpleNamespace(kind="agent") chat_result = SimpleNamespace(kind="chat") @@ -606,47 +621,61 @@ def test_convert_to_app_config_should_route_to_correct_manager( monkeypatch.setattr(converter_module.ChatAppConfigManager, "get_app_config", chat_get_app_config) monkeypatch.setattr(converter_module.CompletionAppConfigManager, "get_app_config", completion_get_app_config) monkeypatch.setattr(converter_module, "load_annotation_reply_config", load_annotation_reply) - agent_mode_app = _app_model(mode=AppMode.AGENT_CHAT, is_agent_with_session=MagicMock(return_value=False)) - agent_flag_app = _app_model(mode=AppMode.CHAT, is_agent_with_session=MagicMock(return_value=True)) - chat_app = _app_model(mode=AppMode.CHAT, is_agent_with_session=MagicMock(return_value=False)) - completion_app = _app_model(mode=AppMode.COMPLETION, is_agent_with_session=MagicMock(return_value=False)) + agent_mode_app = _app_model(id="app-1", mode=AppMode.AGENT_CHAT, app_model_config_id="cfg-1") + agent_flag_app = _app_model(id="app-2", mode=AppMode.CHAT, app_model_config_id="cfg-2") + chat_app = _app_model(id="app-3", mode=AppMode.CHAT, app_model_config_id="cfg-3") + completion_app = _app_model(id="app-4", mode=AppMode.COMPLETION, app_model_config_id="cfg-4") agent_mode_config = _app_model_config(id="cfg-1", app_id="app-1") - agent_flag_config = _app_model_config(id="cfg-2", app_id="app-2") + agent_flag_config = _app_model_config( + id="cfg-2", app_id="app-2", agent_mode=json.dumps({"enabled": True, "strategy": "react"}) + ) chat_config = _app_model_config(id="cfg-3", app_id="app-3") completion_config = _app_model_config(id="cfg-4", app_id="app-4") + sqlite_session.add_all( + [ + agent_mode_app, + agent_flag_app, + chat_app, + completion_app, + agent_mode_config, + agent_flag_config, + chat_config, + completion_config, + ] + ) + sqlite_session.commit() from_agent_mode = converter._convert_to_app_config( app_model=agent_mode_app, app_model_config=agent_mode_config, - session=unbound_session, + session=sqlite_session, ) from_agent_flag = converter._convert_to_app_config( app_model=agent_flag_app, app_model_config=agent_flag_config, - session=unbound_session, + session=sqlite_session, ) from_chat_mode = converter._convert_to_app_config( app_model=chat_app, app_model_config=chat_config, - session=unbound_session, + session=sqlite_session, ) from_completion_mode = converter._convert_to_app_config( app_model=completion_app, app_model_config=completion_config, - session=unbound_session, + session=sqlite_session, ) assert from_agent_mode is agent_result assert from_agent_flag is agent_result assert from_chat_mode is chat_result assert from_completion_mode is completion_result - agent_flag_app.is_agent_with_session.assert_called_once_with(session=unbound_session) load_annotation_reply.assert_has_calls( [ - call(unbound_session, "app-1"), - call(unbound_session, "app-2"), - call(unbound_session, "app-3"), - call(unbound_session, "app-4"), + call(sqlite_session, "app-1"), + call(sqlite_session, "app-2"), + call(sqlite_session, "app-3"), + call(sqlite_session, "app-4"), ] ) assert all( @@ -659,7 +688,7 @@ def test_convert_to_app_config_should_route_to_correct_manager( def test_convert_to_app_config_should_raise_for_invalid_app_mode( converter: WorkflowConverter, unbound_session: Session ) -> None: - app_model = _app_model(mode=AppMode.WORKFLOW, is_agent_with_session=MagicMock(return_value=False)) + app_model = _app_model(mode=AppMode.WORKFLOW) with pytest.raises(ValueError, match="Invalid app mode"): converter._convert_to_app_config( @@ -845,25 +874,35 @@ def test_graph_helpers_should_create_edges_append_nodes_and_choose_mode(converte def test_get_api_based_extension_should_raise_when_extension_not_found( converter: WorkflowConverter, - monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: - db_session = SimpleNamespace(scalar=MagicMock(return_value=None)) - with pytest.raises(ValueError, match="API Based Extension not found"): - converter._get_api_based_extension(tenant_id="tenant-1", api_based_extension_id="ext-1", session=db_session) - db_session.scalar.assert_called_once() + converter._get_api_based_extension(tenant_id="tenant-1", api_based_extension_id="ext-1", session=sqlite_session) def test_get_api_based_extension_should_return_entity_when_found( converter: WorkflowConverter, - monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: - extension = SimpleNamespace(id="ext-1") - db_session = SimpleNamespace(scalar=MagicMock(return_value=extension)) + extension = APIBasedExtension( + tenant_id="tenant-1", + name="API extension", + api_key="encrypted", + api_endpoint="https://example.com", + ) + extension.id = "ext-1" + decoy = APIBasedExtension( + tenant_id="other-tenant", + name="Other tenant API extension", + api_key="encrypted", + api_endpoint="https://example.com", + ) + decoy.id = "ext-other" + sqlite_session.add_all([extension, decoy]) + sqlite_session.commit() result = converter._get_api_based_extension( - tenant_id="tenant-1", api_based_extension_id="ext-1", session=db_session + tenant_id="tenant-1", api_based_extension_id="ext-1", session=sqlite_session ) assert result is extension - db_session.scalar.assert_called_once() diff --git a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py index 25dd16b2d58..e12e8b1b243 100644 --- a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py @@ -27,6 +27,7 @@ from tasks.document_indexing_task import ( normal_document_indexing_task, priority_document_indexing_task, ) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -318,7 +319,7 @@ class TestDocumentIndexing: document_ids=[control_document_id], ) _patch_features(monkeypatch, features) - monkeypatch.setattr("tasks.document_indexing_task.dify_config.BATCH_UPLOAD_LIMIT", str(batch_limit)) + apply_config_overrides(monkeypatch, BATCH_UPLOAD_LIMIT=str(batch_limit)) _document_indexing(dataset_id, document_ids) diff --git a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py index b8cca3a1171..3b320f4a0ec 100644 --- a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py +++ b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py @@ -14,6 +14,7 @@ from core.workflow.nodes.human_input.entities import FormDefinition from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from models.human_input import HumanInputForm from tasks import human_input_timeout_tasks as task_module +from tests.unit_tests.config_override import apply_config_overrides class _FakeService: @@ -103,7 +104,7 @@ def test_check_and_handle_human_input_timeouts_marks_and_routes( ): now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=3600) forms = [ _build_form( @@ -180,7 +181,7 @@ def test_check_and_handle_human_input_timeouts_orders_by_id_before_limit( ): now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=0) forms = [ _build_form( @@ -222,7 +223,7 @@ def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled ): now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=0) old_unexpired_form = _build_form( form_id="form-old", @@ -263,7 +264,7 @@ def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to # workflow_run_id — which previously raised and was swallowed by the except. now = datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) - monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + apply_config_overrides(monkeypatch, HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS=3600) form = _build_form( form_id="form-chat", diff --git a/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py b/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py index fa9c3fc671c..613bf58f145 100644 --- a/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py +++ b/api/tests/unit_tests/tasks/test_initialize_created_app_rbac_access_task.py @@ -2,12 +2,13 @@ from unittest.mock import MagicMock import pytest +from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task +from tests.unit_tests.config_override import apply_config_overrides + APP_RBAC_QUEUE = "app_rbac" def test_initialize_created_app_rbac_access_task_uses_rbac_queue(): - from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task - assert initialize_created_app_rbac_access_task.queue == APP_RBAC_QUEUE @@ -21,7 +22,7 @@ def test_initialize_created_app_rbac_access_task_batches_workspace_members(monke import tasks.initialize_created_app_rbac_access_task as task_module from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task - monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( task_module.TenantService, "iter_member_account_id_batches", @@ -84,7 +85,7 @@ def test_initialize_created_app_rbac_access_task_retries_on_failure(monkeypatch: import tasks.initialize_created_app_rbac_access_task as task_module from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task - monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr( task_module.TenantService, "iter_member_account_id_batches", @@ -147,7 +148,7 @@ def test_sync_joined_workspace_member_rbac_access_task_appends_auto_included_res dataset_append = MagicMock() agent_append = MagicMock() - monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True) + apply_config_overrides(monkeypatch, RBAC_ENABLED=True) monkeypatch.setattr(task_module, "_iter_resource_config_batches", lambda tenant_id, batch_size: iter([resources])) monkeypatch.setattr(rbac.RBACService.ResourceWhitelistConfigs, "batch_get", batch_get) monkeypatch.setattr(rbac.RBACService.AppAccess, "append_whitelist_members_batch", app_append) diff --git a/api/tests/unit_tests/tasks/test_install_default_plugins_task.py b/api/tests/unit_tests/tasks/test_install_default_plugins_task.py index 3a0357c24c5..f7e44be1a50 100644 --- a/api/tests/unit_tests/tasks/test_install_default_plugins_task.py +++ b/api/tests/unit_tests/tasks/test_install_default_plugins_task.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, call import pytest from celery.exceptions import Retry +from tests.unit_tests.config_override import apply_config_overrides + def test_install_default_plugins_task_uses_plugin_queue() -> None: from tasks.install_default_plugins_task import install_default_plugins_task @@ -71,7 +73,7 @@ def test_install_default_plugins_task_queues_model_configuration_after_daemon_in plugin_id = "langgenius/openai" plugin_identifier = "langgenius/openai:1.0.0@aaa" - monkeypatch.setattr(task_module.dify_config, "NEW_USER_DEFAULT_MODELS", "llm:provider:model") + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_MODELS="llm:provider:model") monkeypatch.setattr( task_module.marketplace, "batch_fetch_plugin_manifests", @@ -96,7 +98,7 @@ def test_configure_default_models_task_retries_while_plugins_are_installing( import tasks.install_default_plugins_task as task_module from tasks.install_default_plugins_task import configure_default_models_task - monkeypatch.setattr(task_module.dify_config, "NEW_USER_DEFAULT_MODELS", "llm:provider:model") + apply_config_overrides(monkeypatch, NEW_USER_DEFAULT_MODELS="llm:provider:model") monkeypatch.setattr( task_module.PluginService, "fetch_install_task", @@ -115,10 +117,11 @@ def test_configure_default_models_task_sets_each_explicit_model(monkeypatch: pyt import tasks.install_default_plugins_task as task_module from tasks.install_default_plugins_task import configure_default_models_task - monkeypatch.setattr( - task_module.dify_config, - "NEW_USER_DEFAULT_MODELS", - ("llm:langgenius/openai/openai:gpt-4o-mini,text-embedding:langgenius/openai/openai:text-embedding-3-small"), + apply_config_overrides( + monkeypatch, + NEW_USER_DEFAULT_MODELS=( + "llm:langgenius/openai/openai:gpt-4o-mini,text-embedding:langgenius/openai/openai:text-embedding-3-small" + ), ) fetch_install_task = MagicMock( return_value=SimpleNamespace( diff --git a/api/tests/unit_tests/tasks/test_new_agent_beta_task.py b/api/tests/unit_tests/tasks/test_new_agent_beta_task.py index f6fba0cb7ed..18fbe774117 100644 --- a/api/tests/unit_tests/tasks/test_new_agent_beta_task.py +++ b/api/tests/unit_tests/tasks/test_new_agent_beta_task.py @@ -18,6 +18,7 @@ from tasks.new_agent_beta_task import ( schedule_new_agent_beta_ensure, schedule_new_agent_beta_workflow_ensure, ) +from tests.unit_tests.config_override import apply_config_overrides class _TaskWithQueue(Protocol): @@ -25,9 +26,12 @@ class _TaskWithQueue(Protocol): def _configure_cloud_publish(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_START_AT", datetime(2026, 8, 12, tzinfo=UTC)) - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_END_AT", datetime(2026, 8, 13, tzinfo=UTC)) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + NEW_AGENT_BETA_ACTIVITY_START_AT=datetime(2026, 8, 12, tzinfo=UTC), + NEW_AGENT_BETA_ACTIVITY_END_AT=datetime(2026, 8, 13, tzinfo=UTC), + ) @pytest.mark.parametrize("sqlite_session", [(AgentConfigRevision,)], indirect=True) @@ -132,7 +136,7 @@ def test_rolled_back_workflow_publish_is_never_dispatched( def test_non_cloud_publish_skips_revision_lookup(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) session = MagicMock() register_new_agent_beta_publish_after_commit( @@ -161,8 +165,11 @@ def test_publish_activity_window_is_inclusive_start_exclusive_end( published_at: datetime, expected: bool, ) -> None: - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_START_AT", start) - monkeypatch.setattr(task_module.dify_config, "NEW_AGENT_BETA_ACTIVITY_END_AT", end) + apply_config_overrides( + monkeypatch, + NEW_AGENT_BETA_ACTIVITY_START_AT=start, + NEW_AGENT_BETA_ACTIVITY_END_AT=end, + ) assert task_module._is_publish_in_activity_window(published_at) is expected diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 125e447a10e..8774760f2c4 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -23,6 +23,7 @@ from tasks.remove_app_and_related_data_task import ( _delete_workflow_agent_node_bindings, delete_draft_variables_batch, ) +from tests.unit_tests.config_override import apply_config_overrides def test_delete_workflow_agent_node_bindings_is_scoped_to_tenant_and_app(sqlite_session: Session) -> None: @@ -75,7 +76,7 @@ def test_delete_workflow_agent_node_bindings_is_scoped_to_tenant_and_app(sqlite_ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest.MonkeyPatch) -> None: events: list[str] = [] - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) other_cleanup_names = ( "_delete_app_model_configs", "_delete_app_site", diff --git a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py index 684ee06edf3..c9d20849af7 100644 --- a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py +++ b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py @@ -1,154 +1,260 @@ -"""Unit tests for the ``resume_agent_app_execution`` celery task (ENG-635). - -Every DB access (``db.session.get``) and the generator are patched at the module -level, so the task's branch logic is exercised without a database or live stack. -""" +"""Unit tests for the ``resume_agent_app_execution`` Celery task (ENG-635).""" from __future__ import annotations -from unittest.mock import MagicMock +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from uuid import uuid4 +import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm import Session, scoped_session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom -from models.account import Account +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import ConversationFromSource, EndUserType +from models.enums import InvokeFrom as StoredInvokeFrom from models.human_input import HumanInputForm -from models.model import App, Conversation, EndUser +from models.model import App, AppMode, Conversation, EndUser from tasks.app_generate import resume_agent_app_task as mod MODULE = "tasks.app_generate.resume_agent_app_task" -def _form(conversation_id: str = "conv-1", app_id: str = "app-1") -> MagicMock: - return MagicMock(conversation_id=conversation_id, app_id=app_id) +@pytest.fixture +def task_session(mocker: MockerFixture, sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]: + """Bind the task's Flask-SQLAlchemy session proxy to the shared SQLite database.""" + registry = scoped_session(sqlite_session_factory) + mocker.patch.object(mod.db, "session", registry) + session = registry() + yield session + registry.remove() -def _wire_db( - mocker: MockerFixture, +def _app(*, app_id: str, tenant_id: str) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Agent app", + description="", + mode=AppMode.AGENT_CHAT, + icon_type=None, + icon=None, + icon_background=None, + enable_site=False, + enable_api=False, + ) + + +def _conversation( *, - form=None, - app=None, - conversation=None, - account=None, - end_user=None, -) -> MagicMock: - """Patch the module ``db`` so ``db.session.get(Model, id)`` dispatches by model.""" - table = { - HumanInputForm: form, - App: app, - Conversation: conversation, - Account: account, - EndUser: end_user, - } - db = mocker.patch(f"{MODULE}.db") - db.session.get.side_effect = lambda model, _id: table.get(model) - return db + conversation_id: str, + app_id: str, + account_id: str | None = None, + end_user_id: str | None = None, + invoke_from: StoredInvokeFrom = StoredInvokeFrom.WEB_APP, +) -> Conversation: + return Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.AGENT_CHAT, + name="Agent conversation", + inputs={}, + invoke_from=invoke_from, + from_source=ConversationFromSource.API, + from_account_id=account_id, + from_end_user_id=end_user_id, + ) -def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - account = MagicMock() - app = MagicMock(tenant_id="tenant-1") - db = _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - account.set_tenant_id_with_session.assert_called_once_with("tenant-1", session=db.session.return_value) - gen.return_value.resume_after_form_submission.assert_called_once() - kwargs = gen.return_value.resume_after_form_submission.call_args.kwargs - assert kwargs["conversation_id"] == "conv-1" - assert kwargs["form_id"] == "form-1" - assert kwargs["user"] is account - assert kwargs["app_model"] is app - assert kwargs["invoke_from"] == InvokeFrom.WEB_APP - assert kwargs["session"] is db.session.return_value +def _form(*, form_id: str, conversation_id: str, app_id: str) -> HumanInputForm: + return HumanInputForm( + id=form_id, + tenant_id=str(uuid4()), + app_id=app_id, + workflow_run_id=None, + conversation_id=conversation_id, + form_kind=HumanInputFormKind.RUNTIME, + node_id="ask-human", + form_definition="{}", + rendered_content="Question", + status=HumanInputFormStatus.WAITING, + expiration_time=datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1), + ) -def test_resume_end_user_path(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1", invoke_from=InvokeFrom.WEB_APP) - end_user = MagicMock() - _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user +def _seed_account(session: Session, *, tenant_id: str, account_id: str) -> Account: + tenant = Tenant(name="Tenant") + tenant.id = tenant_id + account = Account(name="Account", email="account@example.com") + account.id = account_id + join = TenantAccountJoin( + tenant_id=tenant_id, + account_id=account_id, + current=True, + role=TenantAccountRole.NORMAL, + ) + session.add_all([tenant, account, join]) + return account -def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.DEBUGGER) - account = MagicMock() - app = MagicMock(tenant_id="tenant-1") - _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + account = _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + conversation = _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id) + task_session.add_all([app, conversation, _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id)]) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - assert gen.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER + call = generator.return_value.resume_after_form_submission.call_args + assert call is not None + assert call.kwargs["conversation_id"] == conversation_id + assert call.kwargs["form_id"] == form_id + assert call.kwargs["user"] is account + assert call.kwargs["app_model"] is app + assert call.kwargs["invoke_from"] == InvokeFrom.WEB_APP + assert isinstance(call.kwargs["session"], Session) + assert account.current_tenant_id == tenant_id -def test_resume_returns_when_form_missing(mocker: MockerFixture): - _wire_db(mocker, form=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_end_user_path(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, end_user_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + end_user = EndUser( + id=end_user_id, + tenant_id=tenant_id, + app_id=app_id, + type=EndUserType.BROWSER, + name="End user", + session_id="browser-session", + ) + task_session.add_all( + [ + app, + end_user, + _conversation(conversation_id=conversation_id, app_id=app_id, end_user_id=end_user_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - gen.assert_not_called() + assert generator.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user -def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture): - _wire_db(mocker, form=_form(conversation_id="other-conv")) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + task_session.add_all( + [ + app, + _conversation( + conversation_id=conversation_id, + app_id=app_id, + account_id=account_id, + invoke_from=StoredInvokeFrom.DEBUGGER, + ), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - gen.assert_not_called() + assert generator.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER -def test_resume_returns_when_app_missing(mocker: MockerFixture): - _wire_db(mocker, form=_form(), app=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +@pytest.mark.usefixtures("task_session") +def test_resume_returns_when_form_missing(mocker: MockerFixture) -> None: + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=str(uuid4())) + generator.assert_not_called() -def test_resume_returns_when_conversation_missing(mocker: MockerFixture): - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture, task_session: Session) -> None: + app_id, form_id = str(uuid4()), str(uuid4()) + task_session.add(_form(form_id=form_id, conversation_id=str(uuid4()), app_id=app_id)) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=form_id) + generator.assert_not_called() -def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_when_app_missing(mocker: MockerFixture, task_session: Session) -> None: + conversation_id, form_id = str(uuid4()), str(uuid4()) + task_session.add(_form(form_id=form_id, conversation_id=conversation_id, app_id=str(uuid4()))) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() -def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_when_conversation_missing(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() -def test_resume_swallows_generator_exception(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock()) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") +def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() - # The task must not propagate the failure (it is logged and the session closed). - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + +def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id, account_id=str(uuid4())), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() + + +def test_resume_swallows_generator_exception(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + generator.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") + + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + + generator.return_value.resume_after_form_submission.assert_called_once() diff --git a/api/tests/unit_tests/test_app_factory.py b/api/tests/unit_tests/test_app_factory.py index acdeecc07c0..64f0dc230f5 100644 --- a/api/tests/unit_tests/test_app_factory.py +++ b/api/tests/unit_tests/test_app_factory.py @@ -10,6 +10,7 @@ from app_factory import create_flask_app_with_configs from enums import DeploymentEdition from libs.external_api import ExternalApi from services.entities.feature_entities import LicenseStatus +from tests.unit_tests.config_override import config_overrides_context INVALID_STATUSES = [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST] VALID_STATUSES = [LicenseStatus.ACTIVE, LicenseStatus.EXPIRING] @@ -20,11 +21,11 @@ def _license(status: LicenseStatus | None): def _enterprise(): - return patch("app_factory.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + return config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) def _community(): - return patch("app_factory.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + return config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) @pytest.fixture diff --git a/api/tests/unit_tests/test_config_overrides.py b/api/tests/unit_tests/test_config_overrides.py index 39588272246..7c71518cea3 100644 --- a/api/tests/unit_tests/test_config_overrides.py +++ b/api/tests/unit_tests/test_config_overrides.py @@ -1,12 +1,101 @@ """Contract tests for the shared unit-test config override fixture.""" +import ast from collections.abc import Callable +from pathlib import Path +from typing import override import pytest from configs import dify_config from enums import DeploymentEdition +_UNIT_TEST_ROOT = Path(__file__).parent +_AUTHORIZED_MUTATION_FILE = _UNIT_TEST_ROOT / "config_override.py" + + +def _references_shared_config(node: ast.AST) -> bool: + """Return whether an expression resolves through the shared ``dify_config`` object.""" + return any( + (isinstance(child, ast.Name) and child.id == "dify_config") + or (isinstance(child, ast.Attribute) and child.attr == "dify_config") + for child in ast.walk(node) + ) + + +def _attribute_chain(node: ast.AST) -> tuple[str, ...]: + parts: list[str] = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + parts.append(node.id) + return tuple(reversed(parts)) + + +def _is_config_field(name: object) -> bool: + return isinstance(name, str) and bool(name) and name.isupper() + + +class _DirectConfigMutationVisitor(ast.NodeVisitor): + """Find test code that bypasses the validated config override helper.""" + + def __init__(self) -> None: + self.lines: list[int] = [] + + @override + def visit_Call(self, node: ast.Call) -> None: + chain = _attribute_chain(node.func) + if chain[-2:] == ("patch", "object") and len(node.args) >= 2: + field = node.args[1] + if ( + _references_shared_config(node.args[0]) + and isinstance(field, ast.Constant) + and _is_config_field(field.value) + ): + self.lines.append(node.lineno) + elif chain[-1:] == ("patch",) and node.args: + target = node.args[0] + if ( + isinstance(target, ast.Constant) + and isinstance(target.value, str) + and ".dify_config." in target.value + and _is_config_field(target.value.rsplit(".", 1)[-1]) + ): + self.lines.append(node.lineno) + elif chain[-2:] == ("monkeypatch", "setattr") and node.args: + target = node.args[0] + field = node.args[1] if len(node.args) >= 2 else None + string_target_is_config = ( + isinstance(target, ast.Constant) and isinstance(target.value, str) and ".dify_config." in target.value + ) + object_target_is_config = ( + field is not None + and _references_shared_config(target) + and isinstance(field, ast.Constant) + and _is_config_field(field.value) + ) + if string_target_is_config or object_target_is_config: + self.lines.append(node.lineno) + self.generic_visit(node) + + @override + def visit_Assign(self, node: ast.Assign) -> None: + for target in node.targets: + if ( + isinstance(target, ast.Attribute) + and _is_config_field(target.attr) + and _references_shared_config(target) + ): + self.lines.append(node.lineno) + self.generic_visit(node) + + +def _find_direct_config_mutations(path: Path) -> list[int]: + visitor = _DirectConfigMutationVisitor() + visitor.visit(ast.parse(path.read_text(), filename=str(path))) + return visitor.lines + def test_config_overrides_updates_shared_config(config_overrides: Callable[..., None]) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) @@ -17,3 +106,14 @@ def test_config_overrides_updates_shared_config(config_overrides: Callable[..., def test_config_overrides_rejects_unknown_fields(config_overrides: Callable[..., None]) -> None: with pytest.raises(ValueError, match=r"Unknown DifyConfig fields: \['NOT_A_CONFIG_FIELD'\]"): config_overrides(NOT_A_CONFIG_FIELD=True) + + +def test_unit_tests_use_validated_config_overrides() -> None: + """Keep global application config mutations centralized and automatically restored.""" + violations = { + str(path.relative_to(_UNIT_TEST_ROOT)): lines + for path in _UNIT_TEST_ROOT.rglob("*.py") + if path != _AUTHORIZED_MUTATION_FILE and (lines := _find_direct_config_mutations(path)) + } + + assert violations == {}, f"Use config_overrides or config_overrides_context instead: {violations}" diff --git a/api/tests/unit_tests/test_constants.py b/api/tests/unit_tests/test_constants.py index e40744a894a..d66cdff65db 100644 --- a/api/tests/unit_tests/test_constants.py +++ b/api/tests/unit_tests/test_constants.py @@ -4,6 +4,7 @@ import pytest import constants from configs import dify_config +from tests.unit_tests.config_override import apply_config_overrides @pytest.mark.parametrize("etl_type", ["SelfHosted", "Unstructured"]) @@ -12,14 +13,16 @@ def test_document_extensions_include_odt_for_document_etl_modes(monkeypatch: pyt original_unstructured_api_url = dify_config.UNSTRUCTURED_API_URL try: - monkeypatch.setattr(dify_config, "ETL_TYPE", etl_type) - monkeypatch.setattr(dify_config, "UNSTRUCTURED_API_URL", None) + apply_config_overrides(monkeypatch, ETL_TYPE=etl_type, UNSTRUCTURED_API_URL=None) reloaded_constants = importlib.reload(constants) assert "odt" in reloaded_constants.DOCUMENT_EXTENSIONS assert "ODT" in reloaded_constants.DOCUMENT_EXTENSIONS finally: - monkeypatch.setattr(dify_config, "ETL_TYPE", original_etl_type) - monkeypatch.setattr(dify_config, "UNSTRUCTURED_API_URL", original_unstructured_api_url) + apply_config_overrides( + monkeypatch, + ETL_TYPE=original_etl_type, + UNSTRUCTURED_API_URL=original_unstructured_api_url, + ) importlib.reload(constants) diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 1466a89d947..50294c9bf85 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -4,7 +4,7 @@ # Redis # Redis connection URL for run records and per-run event streams. -DIFY_AGENT_REDIS_URL=redis://:difyai123456localhost:6379/0 +DIFY_AGENT_REDIS_URL=redis://:difyai123456@localhost:6379/0 # Prefix for Redis run-record and event-stream keys. DIFY_AGENT_REDIS_PREFIX=dify-agent @@ -24,14 +24,14 @@ DIFY_AGENT_PLUGIN_DAEMON_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc # Base URL for Dify API inner endpoints used by Agent Stub config and file requests. DIFY_AGENT_INNER_API_URL=http://localhost:5001 # Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY. -DIFY_AGENT_INNER_API_KEY= +DIFY_AGENT_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 # Runtime resources # Select one coherent Home Snapshot + Execution Binding backend: local, enterprise, or e2b. DIFY_AGENT_RUNTIME_BACKEND=local # Local backend: shellctl data-plane URL and optional bearer token. # Leave the endpoint empty when this server will not provide dify.runtime or resource endpoints. -DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT= +DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT=http://localhost:5004 DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN= # Enterprise resource operations currently fail fast with NotImplementedError. # These names are retained for the configured Enterprise Gateway boundary. @@ -54,12 +54,12 @@ DIFY_AGENT_SHELL_REDACT_PATTERNS= # Public Agent Stub URL reachable from shellctl-managed remote machines. # Use an HTTP(S) service root or an explicit /agent-stub API root. # Leave empty to avoid injecting DIFY_AGENT_STUB_* into shell.run jobs. -DIFY_AGENT_STUB_API_BASE_URL=http://localhost:5050/agent-stub +DIFY_AGENT_STUB_API_BASE_URL=http://host.docker.internal:5050/agent-stub # Optional bind override used only when DIFY_AGENT_STUB_API_BASE_URL uses grpc://. DIFY_AGENT_STUB_GRPC_BIND_ADDRESS= # Dify API base URL reachable from the Sandbox for the signed /files/* data plane, # including Config file and skill pulls. -DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://localhost:5001 +DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://host.docker.internal:5001 # Maximum Agent Stub upload size in MiB; forwarded to Dify API as a signed byte limit. DIFY_AGENT_STUB_UPLOAD_FILE_SIZE_LIMIT=50 # Shell command deadline for converting a Binding file to a ToolFile. @@ -84,6 +84,6 @@ DIFY_AGENT_OUTBOUND_HTTP_POOL_TIMEOUT=10 DIFY_AGENT_OUTBOUND_HTTP_MAX_CONNECTIONS=100 DIFY_AGENT_OUTBOUND_HTTP_MAX_KEEPALIVE_CONNECTIONS=20 DIFY_AGENT_OUTBOUND_HTTP_KEEPALIVE_EXPIRY=30 -DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT= -DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT= -DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT= +DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/home/dify +DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/workspace +DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/home/dify/.snapshots diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 9fa3bc98f46..0f32cb0a6bf 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -127,6 +127,30 @@ services: networks: - ssrf_proxy_network + # Local sandbox for Dify Agent shell workspaces (shellctl data plane). + # Exposes port 5004 on the host so a locally-run agent backend can reach it + # at http://localhost:5004 (DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT). + local_sandbox: + image: langgenius/dify-agent-local-sandbox:1.17.0 + restart: always + env_file: + - ./middleware.env + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN:-${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}} + ports: + - "${EXPOSE_LOCAL_SANDBOX_PORT:-5004}:5004" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + volumes: + - dify_agent_local_sandbox_home:/home/dify + - dify_agent_local_sandbox_workspace:/workspace + # plugin daemon plugin_daemon: image: langgenius/dify-plugin-daemon:0.6.10-local @@ -259,3 +283,7 @@ networks: ssrf_proxy_network: driver: bridge internal: true + +volumes: + dify_agent_local_sandbox_home: + dify_agent_local_sandbox_workspace: diff --git a/docker/envs/middleware.env.example b/docker/envs/middleware.env.example index 3ff8139ad16..faf8307ff43 100644 --- a/docker/envs/middleware.env.example +++ b/docker/envs/middleware.env.example @@ -106,6 +106,12 @@ SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 SANDBOX_PORT=8194 +# ------------------------------ +# Environment Variables for local_sandbox Service (Dify Agent shell workspaces) +# ------------------------------ +# Leave empty to disable shellctl auth (local development default). +DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN= + # ------------------------------ # Environment Variables for ssrf_proxy Service # ------------------------------ @@ -145,6 +151,7 @@ EXPOSE_POSTGRES_PORT=5432 EXPOSE_MYSQL_PORT=3306 EXPOSE_REDIS_PORT=6379 EXPOSE_SANDBOX_PORT=8194 +EXPOSE_LOCAL_SANDBOX_PORT=5004 EXPOSE_SSRF_PROXY_PORT=3128 EXPOSE_WEAVIATE_PORT=8080 diff --git a/e2e/cucumber.config.ts b/e2e/cucumber.config.ts index b7768c36d7b..3f443ba9faa 100644 --- a/e2e/cucumber.config.ts +++ b/e2e/cucumber.config.ts @@ -3,7 +3,7 @@ import './scripts/env-register' const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags=')) const defaultNonExternalTags = - 'not @axe and not @prepared and not @external-model and not @external-tool' + 'not @axe and not @prepared and not @external-model and not @external-tool and not @marketplace-performance' const selectedTags = process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags) const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip' diff --git a/e2e/features/marketplace-performance.feature b/e2e/features/marketplace-performance.feature new file mode 100644 index 00000000000..36c8ddca28f --- /dev/null +++ b/e2e/features/marketplace-performance.feature @@ -0,0 +1,5 @@ +@marketplace-performance +Feature: Embedded Marketplace performance budget + Scenario: The first Marketplace collection stays within the initial rendering budget + When I measure the embedded Marketplace under Fast 4G and 4x CPU throttling + Then the embedded Marketplace should meet its initial rendering budgets diff --git a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts index 29cbc883992..0a30ed03150 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -86,7 +86,7 @@ When( const copyName = createE2EResourceName('Agent', 'copy') await page.goto('/agents') - const card = page.getByRole('article', { name: agentName, exact: true }) + const card = page.getByRole('listitem', { name: agentName, exact: true }) await expect(card).toBeVisible({ timeout: 30_000 }) await card.hover() diff --git a/e2e/features/step-definitions/marketplace-performance.steps.ts b/e2e/features/step-definitions/marketplace-performance.steps.ts new file mode 100644 index 00000000000..fbf5af25e22 --- /dev/null +++ b/e2e/features/step-definitions/marketplace-performance.steps.ts @@ -0,0 +1,104 @@ +import type { DifyWorld, MarketplacePerformanceMetrics } from '../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { e2eBrowser } from '../../test-env' + +// Baseline against the frozen marketplace fixture stub: the first card lands +// around 2.3-2.6s under Fast 4G + 4x CPU throttling (dominated by the ~630KB +// server-rendered HTML), so 4s guards regressions with headroom for slower CI +// runners. The stub serves a frozen recommend banner, so the measured first +// screen also includes the trending carousel and its background image. +const FIRST_CARD_BUDGET_MS = 4_000 +const DOCUMENT_ELEMENT_BUDGET = 2_000 +// Hydrating the server-rendered list peaks around ~220ms on shared CI runners +// under 4x CPU throttling; 300ms still flags pathological main-thread work. +const LONG_TASK_BUDGET_MS = 300 +const FAST_4G_DOWNLOAD_BYTES_PER_SECOND = 4_000_000 / 8 +const FAST_4G_UPLOAD_BYTES_PER_SECOND = 3_000_000 / 8 + +type PerformanceWindow = Window & { + __marketplaceLongTaskDurations?: number[] +} + +When( + 'I measure the embedded Marketplace under Fast 4G and 4x CPU throttling', + async function (this: DifyWorld) { + if (e2eBrowser !== 'chromium') + throw new Error('The Marketplace performance benchmark requires E2E_BROWSER=chromium.') + if (!this.context) + throw new Error('Playwright context has not been initialized for this scenario.') + + const page = this.getPage() + const cdpSession = await this.context.newCDPSession(page) + + try { + await page.addInitScript(() => { + const performanceWindow = window as PerformanceWindow + performanceWindow.__marketplaceLongTaskDurations = [] + + if (!PerformanceObserver.supportedEntryTypes.includes('longtask')) return + + const observer = new PerformanceObserver((entries) => { + performanceWindow.__marketplaceLongTaskDurations!.push( + ...entries.getEntries().map((entry) => entry.duration), + ) + }) + observer.observe({ type: 'longtask', buffered: true }) + }) + + await cdpSession.send('Network.enable') + await cdpSession.send('Network.emulateNetworkConditions', { + connectionType: 'cellular4g', + downloadThroughput: FAST_4G_DOWNLOAD_BYTES_PER_SECOND, + latency: 60, + offline: false, + uploadThroughput: FAST_4G_UPLOAD_BYTES_PER_SECOND, + }) + await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 }) + + await page.goto('/marketplace', { waitUntil: 'domcontentloaded' }) + await page.locator('[data-marketplace-card]').first().waitFor({ + state: 'visible', + timeout: 30_000, + }) + const firstCardVisibleMs = await page.evaluate(() => performance.now()) + + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }), + ) + + this.marketplacePerformanceMetrics = await page.evaluate( + (visibleMs): MarketplacePerformanceMetrics => { + const performanceWindow = window as PerformanceWindow + const longTaskDurations = performanceWindow.__marketplaceLongTaskDurations ?? [] + + return { + firstCardVisibleMs: visibleMs, + documentElementCount: document.querySelectorAll('*').length, + longestTaskMs: Math.max(0, ...longTaskDurations), + } + }, + firstCardVisibleMs, + ) + } finally { + await cdpSession.detach() + } + }, +) + +Then( + 'the embedded Marketplace should meet its initial rendering budgets', + async function (this: DifyWorld) { + const metrics = this.marketplacePerformanceMetrics + if (!metrics) throw new Error('Marketplace performance metrics were not captured.') + + this.attach(JSON.stringify(metrics, null, 2), 'application/json') + + expect(metrics.firstCardVisibleMs).toBeLessThanOrEqual(FIRST_CARD_BUDGET_MS) + expect(metrics.documentElementCount).toBeLessThanOrEqual(DOCUMENT_ELEMENT_BUDGET) + expect(metrics.longestTaskMs).toBeLessThanOrEqual(LONG_TASK_BUDGET_MS) + }, +) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 04d73fa8dde..794c439a187 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -73,6 +73,12 @@ export const createAgentBuilderWorldState = () => ({ export type AgentBuilderWorldState = ReturnType +export type MarketplacePerformanceMetrics = { + firstCardVisibleMs: number + documentElementCount: number + longestTaskMs: number +} + export class DifyWorld extends World { context: BrowserContext | undefined consoleRequestContext: APIRequestContext | undefined @@ -97,6 +103,7 @@ export class DifyWorld extends World { capturedDownloads: Download[] = [] shareURL: string | undefined sharedAppPage: Page | undefined + marketplacePerformanceMetrics: MarketplacePerformanceMetrics | undefined constructor(options: IWorldOptions) { super(options) @@ -121,6 +128,7 @@ export class DifyWorld extends World { this.capturedDownloads = [] this.shareURL = undefined this.sharedAppPage = undefined + this.marketplacePerformanceMetrics = undefined } async startSession(browser: Browser, authenticated: boolean) { diff --git a/e2e/package.json b/e2e/package.json index 871766c9714..48a0b0f4e8b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -15,6 +15,7 @@ "e2e:install": "playwright install --with-deps chromium webkit", "e2e:install:ci": "playwright install --with-deps --only-shell chromium webkit", "e2e:install:ci:chromium": "playwright install --with-deps --only-shell chromium", + "e2e:marketplace-performance": "tsx ./scripts/run-cucumber.ts --full -- --tags @marketplace-performance", "e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down", "e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up", "e2e:post-merge": "tsx ./scripts/run-post-merge.ts", diff --git a/e2e/scripts/common.ts b/e2e/scripts/common.ts index f0969f06543..317bcddc440 100644 --- a/e2e/scripts/common.ts +++ b/e2e/scripts/common.ts @@ -47,6 +47,12 @@ export const e2eWebEnvOverrides = { NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api', NEXT_PUBLIC_ENABLE_AGENT_V2: 'true', NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api', + // NEXT_PUBLIC_* values are inlined into the client bundle at build time, so the + // marketplace prefix (e.g. the frozen fixture stub started by run-cucumber.ts) + // must reach both the build environment and the build stamp hash. + ...(process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX + ? { NEXT_PUBLIC_MARKETPLACE_API_PREFIX: process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX } + : {}), } satisfies Record const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ') diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index 74f93b6d2b3..c59c7fba2f7 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -3,6 +3,7 @@ import { mkdir, readFile, rm } from 'node:fs/promises' import path from 'node:path' import { runCleanupTasks } from '../support/cleanup' import { assertCucumberScenariosStarted } from '../support/cucumber-messages' +import { startMarketplaceStub, stopMarketplaceStub } from '../support/marketplace-stub' import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process' import { startWebServer, stopWebServer } from '../support/web-server' import { apiURL, baseURL, reuseExistingWebServer } from '../test-env' @@ -15,8 +16,27 @@ import './env-register' const hasCustomTags = (forwardArgs: string[]) => forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags=')) +const collectTagExpressions = (forwardArgs: string[]) => { + const expressions: string[] = [] + + for (let index = 0; index < forwardArgs.length; index += 1) { + const arg = forwardArgs[index]! + if (arg === '--tags' && forwardArgs[index + 1]) expressions.push(forwardArgs[index + 1]!) + else if (arg.startsWith('--tags=')) expressions.push(arg.slice('--tags='.length)) + } + + if (process.env.E2E_CUCUMBER_TAGS) expressions.push(process.env.E2E_CUCUMBER_TAGS) + + return expressions +} + +const selectsMarketplacePerformance = (forwardArgs: string[]) => + collectTagExpressions(forwardArgs).some((expression) => + /(? { @@ -90,6 +110,7 @@ const main = async () => { cleanupPromise = (async () => { const cleanupErrors = await runCleanupTasks([ { label: 'Stop web server', run: stopWebServer }, + { label: 'Stop marketplace API stub', run: stopMarketplaceStub }, { label: 'Stop celery worker', run: () => stopManagedProcess(celeryProcess) }, { label: 'Stop API server', run: () => stopManagedProcess(apiProcess) }, { label: 'Stop agent backend', run: () => stopManagedProcess(difyAgentProcess) }, @@ -187,6 +208,18 @@ const main = async () => { logFilePath: path.join(logDir, 'cucumber-celery.log'), }) + // The performance benchmark must not depend on live marketplace.dify.ai + // content, so serve frozen fixtures from a local stub unless the caller + // explicitly points the web app at another marketplace API. + if ( + selectsMarketplacePerformance(forwardArgs) && + !process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX + ) { + const { apiPrefix } = await startMarketplaceStub() + process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX = apiPrefix + console.log(`Marketplace API stub is serving frozen fixtures at ${apiPrefix}.`) + } + await startWebServer({ baseURL, command: 'npx', diff --git a/e2e/support/marketplace-stub.ts b/e2e/support/marketplace-stub.ts new file mode 100644 index 00000000000..8effe368e35 --- /dev/null +++ b/e2e/support/marketplace-stub.ts @@ -0,0 +1,216 @@ +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { createServer } from 'node:http' + +/** + * Local Marketplace API stub for the performance benchmark. + * + * The embedded Marketplace page talks directly to NEXT_PUBLIC_MARKETPLACE_API_PREFIX + * from both the Next.js server and the browser. Serving frozen fixtures from this + * stub keeps the measured first-screen content identical on every run, so the + * rendering budgets do not depend on live marketplace.dify.ai content. + */ + +const stubHost = '127.0.0.1' +const apiPrefixPath = '/api/v1' + +const pluginIconSvg = [ + '', + '', + '', + '', +].join('') + +const makeFrozenPlugin = (name: string, label: string, installCount: number) => ({ + type: 'plugin', + org: 'e2e-fixtures', + name, + plugin_id: `e2e-fixtures/${name}`, + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: `e2e-fixtures/${name}:1.0.0`, + icon: 'icon.svg', + verified: true, + label: { en_US: label, zh_Hans: label }, + brief: { + en_US: `${label} is a frozen fixture plugin for the performance benchmark.`, + zh_Hans: `${label} is a frozen fixture plugin for the performance benchmark.`, + }, + introduction: '', + repository: '', + category: 'tool', + install_count: installCount, + endpoint: { settings: [] }, + tags: [{ name: 'search' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +}) + +const makeFrozenCollection = (name: string, label: string) => ({ + name, + label: { en_US: label, zh_Hans: label }, + description: { + en_US: `${label} frozen fixture collection.`, + zh_Hans: `${label} frozen fixture collection.`, + }, + rule: '', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + searchable: false, +}) + +const frozenCollections = [ + makeFrozenCollection('e2e-frozen-featured', 'Frozen Featured'), + makeFrozenCollection('e2e-frozen-popular', 'Frozen Popular'), +] + +// A frozen recommend banner keeps the trending carousel (and its decorative +// background image) inside the measured first screen, so the benchmark covers +// the same rendering paths as production instead of an empty banner state. +const frozenBanners = [ + { + id: 'e2e-frozen-banner-trending', + title: 'Trending', + sort: 1, + language: 'en-US', + style_type: 'recommend', + content: { + theme_type: 'hottest', + heading: 'Frozen Trending Plugins', + description: 'Frozen fixture banner for the performance benchmark.', + cards: Array.from({ length: 4 }, (_, index) => ({ + item_type: 'plugin', + item_id: `e2e-fixtures/featured-plugin-${index + 1}`, + display_name: `Featured Plugin ${index + 1}`, + icon_url: `/api/v1/plugins/e2e-fixtures/featured-plugin-${index + 1}/icon`, + creator: 'e2e-fixtures', + link: '', + card_position: index + 1, + })), + }, + }, +] + +const frozenCollectionPlugins: Record = { + 'e2e-frozen-featured': Array.from({ length: 8 }, (_, index) => + makeFrozenPlugin( + `featured-plugin-${index + 1}`, + `Featured Plugin ${index + 1}`, + 12_000 - index * 100, + ), + ), + 'e2e-frozen-popular': Array.from({ length: 8 }, (_, index) => + makeFrozenPlugin( + `popular-plugin-${index + 1}`, + `Popular Plugin ${index + 1}`, + 8_000 - index * 100, + ), + ), +} + +type StubResponse = { + body: string + contentType: string +} + +const jsonResponse = (data: unknown): StubResponse => ({ + body: JSON.stringify({ code: 0, msg: 'success', data }), + contentType: 'application/json', +}) + +const resolveStubResponse = (method: string, pathname: string): StubResponse | undefined => { + if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: frozenBanners }) + if (method === 'GET' && pathname === '/collections') + return jsonResponse({ collections: frozenCollections }) + + const collectionPluginsMatch = pathname.match(/^\/collections\/([^/]+)\/plugins$/) + if (method === 'POST' && collectionPluginsMatch) { + return jsonResponse({ + plugins: frozenCollectionPlugins[collectionPluginsMatch[1]!] ?? [], + }) + } + + if (method === 'POST' && /^\/(?:plugins|bundles)\/search\/advanced$/.test(pathname)) + return jsonResponse({ plugins: [], bundles: [], total: 0 }) + if (method === 'GET' && pathname === '/template-collections') + return jsonResponse({ collections: [], total: 0 }) + if (method === 'POST' && /^\/template-collections\/[^/]+\/templates$/.test(pathname)) + return jsonResponse({ templates: [], total: 0 }) + if (method === 'POST' && pathname === '/templates/search/advanced') + return jsonResponse({ templates: [], total: 0 }) + if (method === 'GET' && /^\/(?:plugins|bundles)\/[^/]+\/[^/]+\/icon$/.test(pathname)) + return { body: pluginIconSvg, contentType: 'image/svg+xml' } + + return undefined +} + +const handleRequest = (request: IncomingMessage, response: ServerResponse) => { + request.resume() + + const method = request.method ?? 'GET' + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? stubHost}`) + const requestedHeaders = request.headers['access-control-request-headers'] + + response.setHeader('Access-Control-Allow-Origin', '*') + response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') + response.setHeader( + 'Access-Control-Allow-Headers', + Array.isArray(requestedHeaders) ? requestedHeaders.join(',') : (requestedHeaders ?? '*'), + ) + response.setHeader('Cache-Control', 'no-store') + + if (method === 'OPTIONS') { + response.writeHead(204) + response.end() + return + } + + const pathname = url.pathname.startsWith(apiPrefixPath) + ? url.pathname.slice(apiPrefixPath.length) || '/' + : undefined + const stubResponse = pathname === undefined ? undefined : resolveStubResponse(method, pathname) + + if (!stubResponse) { + console.warn(`Marketplace stub has no fixture for ${method} ${url.pathname}; returning 404.`) + response.writeHead(404, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ code: 404, msg: 'Marketplace stub fixture not found', data: null }), + ) + return + } + + response.writeHead(200, { 'Content-Type': stubResponse.contentType }) + response.end(stubResponse.body) +} + +let activeServer: Server | undefined + +export const startMarketplaceStub = async (): Promise<{ apiPrefix: string }> => { + if (activeServer) throw new Error('The Marketplace API stub is already running.') + + const port = Number(process.env.E2E_MARKETPLACE_STUB_PORT || 3620) + const server = createServer(handleRequest) + + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error) + server.once('error', onError) + server.listen(port, stubHost, () => { + server.off('error', onError) + resolve() + }) + }) + + activeServer = server + return { apiPrefix: `http://${stubHost}:${port}${apiPrefixPath}` } +} + +export const stopMarketplaceStub = async () => { + const server = activeServer + activeServer = undefined + if (!server) return + + server.closeAllConnections() + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} diff --git a/knip.config.ts b/knip.config.ts index 2914f398f8c..ddd5dd6e02c 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -15,6 +15,14 @@ const config: KnipConfig = { 'tsslint.config.ts', 'dev-proxy.config.ts', 'plugins/eslint/index.js', + // Consumed by the dify-marketplace repository, which mounts this + // repo as a submodule and imports these modules via path aliases. + 'app/components/plugins/marketplace/index.tsx', + 'app/components/plugins/marketplace/hydration-server.tsx', + 'app/components/plugins/marketplace/server-budget.ts', + 'app/components/plugins/marketplace/creator-profile/model.ts', + 'app/components/plugins/marketplace/home/marketplace-live-search.tsx', + 'app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx', ], project: [ '**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,css,mdx}!', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 9cb1ae7a569..5cbcedae409 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1187,7 +1187,7 @@ }, "web/app/components/base/icons/src/vender/plugin/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 3 + "count": 2 } }, "web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts": { @@ -2607,14 +2607,6 @@ "count": 1 } }, - "web/app/components/plugins/marketplace/list/list-with-collection.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/plugins/plugin-auth/authorized/index.tsx": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index d0a00673acf..ac9af4af710 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -355,6 +355,7 @@ export type OpenApiErrorCode = | 'request_entity_too_large' | 'too_many_files' | 'too_many_requests' + | 'trigger_workflow_service_mode_unavailable' | 'unauthorized' | 'unknown' | 'unsupported_file_type' diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 47ae8d31995..55ddcc32f6e 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -446,6 +446,7 @@ export const zOpenApiErrorCode = z.enum([ 'request_entity_too_large', 'too_many_files', 'too_many_requests', + 'trigger_workflow_service_mode_unavailable', 'unauthorized', 'unknown', 'unsupported_file_type', diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index 459f3d778e6..150c6a9ff21 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -22,6 +22,10 @@ export type MarketplaceCollection = { search_params?: SearchParamsFromCollection } +export type MarketplaceTimestamp = string | number +export type MarketplaceCreatorStatus = 'pending' | 'active' | 'inactive' | 'deleted' +export type MarketplaceOrganizationStatus = 'active' | 'inactive' | 'deleted' + export type PluginsSearchParams = { query: string page?: number @@ -53,9 +57,65 @@ export type MarketplaceTemplate = { icon: string icon_background: string icon_file_key: string - publisher_unique_handle: string + publisher_unique_handle?: string + publisher_handle?: string + publisher_type?: string + creator_email?: string usage_count: number categories: string[] + deps_plugins?: string[] + preferred_languages?: string[] + badges?: string[] + created_at?: MarketplaceTimestamp + updated_at?: MarketplaceTimestamp +} + +export type MarketplaceCreator = { + id?: string + email?: string + name?: string + display_name?: string + unique_handle: string + display_email?: string + description?: string + avatar?: string + background_image?: string + social_links?: string[] + badges?: string[] + verified?: boolean + status?: MarketplaceCreatorStatus + public?: boolean + plugin_count?: number + template_count?: number + created_at?: string + updated_at?: string +} + +export type MarketplaceOrganization = { + id?: string + email?: string + name?: string + display_name?: string + unique_handle?: string + display_email?: string + description?: string + avatar?: string + background_image?: string + social_links?: string[] + badges?: string[] + verified?: boolean + status?: MarketplaceOrganizationStatus + created_at?: string + updated_at?: string +} + +export type MarketplaceTemplateCollection = { + name: string + description: Record + label: Record + searchable?: boolean + search_params?: SearchParamsFromCollection + priority: number } export type MarketplacePluginCategory = @@ -109,6 +169,9 @@ export type MarketplacePlugin = { authorized_category: 'langgenius' | 'partner' | 'community' } from: MarketplacePluginDependencySource + created_at?: MarketplaceTimestamp + updated_at?: MarketplaceTimestamp + version_updated_at?: MarketplaceTimestamp | null } export type PluginInfoFromMarketPlace = { @@ -154,8 +217,151 @@ export type TemplateDetailResponse = { data: MarketplaceTemplate } +export type TemplateCollectionsResponse = { + data?: { + collections?: MarketplaceTemplateCollection[] + total?: number + } +} + +export type TemplateCollectionTemplatesResponse = { + data?: { + templates?: MarketplaceTemplate[] + total?: number + } +} + +export type TemplateSearchResponse = { + data?: { + templates?: MarketplaceTemplate[] + total?: number + } +} + export type DownloadPluginResponse = Blob +export type CreatorDetailResponse = { + code?: number + data?: { + creator?: MarketplaceCreator + } + msg?: string +} + +export type OrganizationDetailResponse = { + code?: number + data?: { + organization?: MarketplaceOrganization + } + msg?: string +} + +export type PublisherPluginsResponse = { + code?: number + data?: { + plugins?: MarketplacePlugin[] + total?: number + } + msg?: string +} + +export type PublisherTemplatesResponse = { + code?: number + data?: { + templates?: MarketplaceTemplate[] + total?: number + } + msg?: string +} + +// Banner payload shapes shared by the standalone marketplace and the embedded +// console. The banners endpoint output stays `unknown` in the contract because +// the delivery format is normalized and runtime-validated in +// `web/app/components/plugins/marketplace/home/banners.ts`. +export type BannerBase = { + id: string + title: string + sort: number + language: string +} + +export type BannerRecommendCard = { + item_type: 'plugin' | 'template' + item_id: string + display_name: string + icon_url?: string + icon?: string + icon_background?: string + creator?: string + badges?: Array<'partner' | 'verified'> + link: string + card_position: number + auto_batch_id?: string | null +} + +export type BannerRecommend = BannerBase & { + style_type: 'recommend' + content: { + theme_type: 'newest' | 'hottest' | 'partner' + heading?: string + subheadings?: string[] + description?: string + cards: BannerRecommendCard[] + } +} + +export type BannerBlog = BannerBase & { + style_type: 'blog' + content: { + blog_title: string + subtitle?: string + description?: string + link: string + link_target_type: 'blog' | 'github' + } +} + +export type BannerImageContent = { + images: { + desktop: string + tablet?: string + mobile?: string + } + link: string + alt_text?: string + activity_id?: string +} + +export type BannerEvent = BannerBase & { + style_type: 'event' + content: BannerImageContent +} + +export type BannerAd = BannerBase & { + style_type: 'ad' + content: BannerImageContent & { + partner_id?: string + campaign_id?: string + } +} + +export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd + +const bannerListContract = base + .route({ + path: '/banners', + method: 'GET', + }) + .input( + type<{ + query: { + page: 'plugins' | 'templates' + language: string + } + }>(), + ) + .output(type()) + const collectionsContract = base .route({ path: '/collections', @@ -212,6 +418,58 @@ const templateDetailContract = base ) .output(type()) +const templateCollectionsContract = base + .route({ + path: '/template-collections', + method: 'GET', + }) + .input( + type<{ + query?: { + page?: number + page_size?: number + } + }>(), + ) + .output(type()) + +const templateCollectionTemplatesContract = base + .route({ + path: '/template-collections/{collectionName}/templates', + method: 'POST', + }) + .input( + type<{ + params: { + collectionName: string + } + body?: { + limit?: number + } + }>(), + ) + .output(type()) + +const templateSearchContract = base + .route({ + path: '/templates/search/advanced', + method: 'POST', + }) + .input( + type<{ + body: { + page: number + page_size: number + query: string + sort_by: string + sort_order: string + categories?: string[] + languages?: string[] + } + }>(), + ) + .output(type()) + const downloadPluginContract = base .route({ path: '/plugins/{organization}/{pluginName}/{version}/download', @@ -228,12 +486,90 @@ const downloadPluginContract = base ) .output(type()) +const creatorDetailContract = base + .route({ + path: '/creators/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + }>(), + ) + .output(type()) + +const organizationDetailContract = base + .route({ + path: '/organizations/{id}', + method: 'GET', + }) + .input( + type<{ + params: { + id: string + } + }>(), + ) + .output(type()) + +const publisherPluginsContract = base + .route({ + path: '/plugins/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query: { + page: number + page_size: number + sort_by?: string + sort_order?: string + } + }>(), + ) + .output(type()) + +const publisherTemplatesContract = base + .route({ + path: '/templates/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query: { + page: number + page_size: number + sort_by?: string + sort_order?: string + } + }>(), + ) + .output(type()) + export const marketplaceRouterContract = { + banners: { + list: bannerListContract, + }, collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, + templateCollections: templateCollectionsContract, + templateCollectionTemplates: templateCollectionTemplatesContract, templateDetail: templateDetailContract, + templateSearch: templateSearchContract, downloadPlugin: downloadPluginContract, + creatorDetail: creatorDetailContract, + organizationDetail: organizationDetailContract, + publisherPlugins: publisherPluginsContract, + publisherTemplates: publisherTemplatesContract, } export type MarketPlaceInputs = InferContractRouterInputs diff --git a/packages/iconify-collections/assets/public/common/gmail.svg b/packages/iconify-collections/assets/public/common/gmail.svg new file mode 100644 index 00000000000..1e5afcbf624 --- /dev/null +++ b/packages/iconify-collections/assets/public/common/gmail.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index 510de02889d..2e6fbd864c8 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1785332090, + "lastModified": 1786856617, "icons": { "agent-building-blocks": { "body": "" @@ -71,7 +71,8 @@ "height": 24 }, "common-d": { - "body": "" + "body": "", + "height": 16 }, "common-diagonal-dividing-line": { "body": "", @@ -89,7 +90,8 @@ "height": 24 }, "common-enter-key": { - "body": "" + "body": "", + "height": 16 }, "common-firecrawl": { "body": "", @@ -106,6 +108,11 @@ "width": 18, "height": 18 }, + "common-gmail": { + "body": "", + "width": 24, + "height": 24 + }, "common-google-drive": { "body": "", "width": 24, @@ -127,10 +134,12 @@ "height": 12 }, "common-lock": { - "body": "" + "body": "", + "height": 16 }, "common-message-chat-square": { - "body": "" + "body": "", + "height": 16 }, "common-multi-path-retrieval": { "body": "", @@ -158,7 +167,8 @@ "height": 14 }, "common-sparkles-soft-accent": { - "body": "" + "body": "", + "height": 16 }, "education-triangle": { "body": "", diff --git a/packages/iconify-collections/custom-public/info.json b/packages/iconify-collections/custom-public/info.json index d283dc036f5..09bd7c5e800 100644 --- a/packages/iconify-collections/custom-public/info.json +++ b/packages/iconify-collections/custom-public/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-public", "name": "Dify Custom Public", - "total": 150, + "total": 151, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index 4daa46430b9..d92c4e2fd1e 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -43,24 +43,6 @@ "body": "", "width": 17 }, - "app-publisher-deploying-chevron": { - "body": "", - "width": 8.27613, - "height": 5.08087 - }, - "deploy-code-block": { - "body": "" - }, - "deploy-line-5": { - "body": "", - "width": 12, - "height": 39 - }, - "deploy-rocket": { - "body": "", - "width": 14, - "height": 14 - }, "features-citations": { "body": "", "width": 24, @@ -1610,6 +1592,24 @@ "body": "", "width": 16, "height": 16 + }, + "app-publisher-deploying-chevron": { + "body": "", + "width": 8.27613, + "height": 5.08087 + }, + "deploy-code-block": { + "body": "" + }, + "deploy-line-5": { + "body": "", + "width": 12, + "height": 39 + }, + "deploy-rocket": { + "body": "", + "width": 14, + "height": 14 } } } diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 097f2c15865..6c61adcc7c4 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 346, + "total": 350, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts index ff65fe8eb68..4795693fe27 100644 --- a/web/__tests__/proxy-frame-options.spec.ts +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -4,6 +4,7 @@ import { canEmbedPath, proxy } from '@/proxy' const mockEnv = vi.hoisted(() => ({ NEXT_PUBLIC_ALLOW_EMBED: false, NEXT_PUBLIC_CSP_WHITELIST: 'https://example.com', + NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: '', NEXT_PUBLIC_TURNSTILE_SITE_KEY: '', })) @@ -24,6 +25,7 @@ const createRequest = (url: string) => { describe('proxy frame options', () => { afterEach(() => { mockEnv.NEXT_PUBLIC_ALLOW_EMBED = false + mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = '' mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = '' vi.unstubAllEnvs() }) @@ -86,6 +88,36 @@ describe('proxy frame options', () => { expect(response.headers.get('x-frame-options')).toBe('DENY') expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") }) + + it('should deny framing for the Marketplace OAuth authorize route', () => { + const response = proxy( + createRequest('https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client'), + ) + + expect(response.headers.get('x-frame-options')).toBe('DENY') + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") + }) + + it('should allow framing Marketplace pages when a Marketplace origin is configured', () => { + vi.stubEnv('NODE_ENV', 'production') + mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = 'https://marketplace.dify.ai' + + const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + + expect(response.headers.get('content-security-policy') ?? '').toMatch( + /frame-src[^;]*https:\/\/marketplace\.dify\.ai/, + ) + }) + + it('should not add a Marketplace frame origin when the prefix is unset', () => { + vi.stubEnv('NODE_ENV', 'production') + + const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + const contentSecurityPolicy = response.headers.get('content-security-policy') ?? '' + + expect(contentSecurityPolicy).toContain('frame-src') + expect(contentSecurityPolicy).not.toContain('https://marketplace.dify.ai') + }) }) describe('proxy education entry normalization', () => { diff --git a/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx b/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx new file mode 100644 index 00000000000..c851acaf03b --- /dev/null +++ b/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx @@ -0,0 +1,106 @@ +import { render, waitFor } from '@testing-library/react' +import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state' +import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { AmplitudeIdentitySync } from '../external-service-sync' + +const { mockSetUserId, mockSetUserProperties, mockTrackEvent } = vi.hoisted(() => ({ + mockSetUserId: vi.fn(), + mockSetUserProperties: vi.fn(), + mockTrackEvent: vi.fn((..._args: unknown[]) => ({ + promise: Promise.resolve({ code: 200 }), + })), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + useSuspenseQuery: () => ({ + data: { + id: 'account-id', + email: 'person@example.com', + name: 'Person', + is_password_set: true, + }, + }), + } +}) + +vi.mock('jotai', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + useAtomValue: () => ({ + id: 'workspace-id', + name: 'Workspace', + plan: 'professional', + role: 'owner', + }), + } +}) + +vi.mock('@/features/account-profile/client', () => ({ + userProfileQueryOptions: () => ({}), +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + setUserId: (...args: unknown[]) => mockSetUserId(...args), + setUserProperties: (...args: unknown[]) => mockSetUserProperties(...args), +})) + +vi.mock('@/app/components/base/amplitude/utils', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/base/amplitude/init', () => ({ + getIsAmplitudeInitialized: () => true, +})) + +vi.mock('@/app/components/base/analytics-consent/consent-store', async (importOriginal) => { + const original = + await importOriginal() + return { + ...original, + getAnalyticsConsent: () => 'granted', + } +}) + +describe('AmplitudeIdentitySync', () => { + beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.clear() + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111', + ) + }) + + it('sets identity before flushing a marker that already exists', async () => { + rememberRegistrationSuccess({ method: 'oauth' }) + + render() + + await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1)) + expect(mockSetUserId).toHaveBeenCalledWith('person@example.com') + expect(mockSetUserProperties).toHaveBeenCalledTimes(1) + expect(mockSetUserId.mock.invocationCallOrder[0]).toBeLessThan( + mockTrackEvent.mock.invocationCallOrder[0]!, + ) + expect(mockSetUserProperties.mock.invocationCallOrder[0]).toBeLessThan( + mockTrackEvent.mock.invocationCallOrder[0]!, + ) + }) + + it('flushes a marker created after identity sync without repeating unchanged identity updates', async () => { + render() + + await waitFor(() => expect(mockSetUserId).toHaveBeenCalledTimes(1)) + expect(mockTrackEvent).not.toHaveBeenCalled() + + rememberRegistrationSuccess({ method: 'email' }) + + await waitFor(() => expect(mockTrackEvent).toHaveBeenCalledTimes(1)) + expect(mockSetUserId).toHaveBeenCalledTimes(1) + expect(mockSetUserProperties).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 26d4d4656ab..9a52e32d0e7 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -275,6 +275,25 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail).toBeUndefined() }) + it('should keep access point content hidden while redirecting cached app data without permission', async () => { + mockPathname = '/app/app-1/access-point' + useStore + .getState() + .setAppDetail(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) + + render( + +
App page content
+
, + ) + + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') + }) + expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + }) + it('should redirect deploy pages when app deploy ACL permission is missing', async () => { mockPathname = '/app/app-1/deploy' mockFetchAppDetailDirect.mockResolvedValue( diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index eb89902aec0..df55021f289 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -78,8 +78,28 @@ const AppDetailLayout: FC = (props) => { appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null const pageTitle = appDetailPageTitle(pathname, t) const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined + const isAppACLContextReady = + !!routeAppDetail && + !!currentWorkspace.id && + !isLoadingCurrentWorkspace && + !isLoadingWorkspacePermissionKeys && + !isLoadingAppDetail + const appACLCapabilities = React.useMemo( + () => + routeAppDetail && isAppACLContextReady + ? getAppACLCapabilities(routeAppDetail.permission_keys, { + currentUserId, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + isRbacEnabled, + }) + : null, + [currentUserId, isAppACLContextReady, isRbacEnabled, routeAppDetail, workspacePermissionKeys], + ) const shouldBlockAgentResourceAccess = routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config') + const shouldBlockAccessPointAccess = + pathname.endsWith('/access-point') && !appACLCapabilities?.canAccessPoint useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`) @@ -120,22 +140,9 @@ const AppDetailLayout: FC = (props) => { }, [appId, router, setAppDetail]) useEffect(() => { - if ( - !routeAppDetail || - !currentWorkspace.id || - isLoadingCurrentWorkspace || - isLoadingWorkspacePermissionKeys || - isLoadingAppDetail - ) - return + if (!routeAppDetail || !isAppACLContextReady || !appACLCapabilities) return if (routeAppDetail.id !== appId) return - const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, { - currentUserId, - resourceMaintainer: routeAppDetail.maintainer, - workspacePermissionKeys, - isRbacEnabled, - }) const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') const isLogsPath = pathname.endsWith('logs') const isAnnotationsPath = pathname.endsWith('annotations') @@ -182,14 +189,12 @@ const AppDetailLayout: FC = (props) => { if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail({ ...appDetailRes, enable_sso: false }) }, [ + appACLCapabilities, appDetail?.id, appDetailRes, appId, currentUserId, - currentWorkspace.id, - isLoadingAppDetail, - isLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys, + isAppACLContextReady, isRbacEnabled, pathname, routeAppDetail, @@ -200,7 +205,7 @@ const AppDetailLayout: FC = (props) => { const isWorkflowPage = pathname.endsWith('/workflow') const content = - !appDetail || shouldBlockAgentResourceAccess ? ( + !appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? (
diff --git a/web/app/(commonLayout)/external-service-sync.tsx b/web/app/(commonLayout)/external-service-sync.tsx index cec7155626e..08bb786d379 100644 --- a/web/app/(commonLayout)/external-service-sync.tsx +++ b/web/app/(commonLayout)/external-service-sync.tsx @@ -5,9 +5,13 @@ import type { DeploymentEdition } from '@dify/contracts/api/console/system-featu import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { Fragment, useEffect, useRef } from 'react' +import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' import { setUserId, setUserProperties } from '@/app/components/base/amplitude' -import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { + flushRegistrationSuccess, + getRegistrationSuccessSnapshot, + subscribeRegistrationSuccess, +} from '@/app/components/base/amplitude/registration-tracking' import { useAmplitudeInitialized } from '@/app/components/base/amplitude/use-amplitude-initialized' import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' @@ -43,13 +47,18 @@ function buildAmplitudeProperties({ return properties } -function AmplitudeIdentitySync() { +export function AmplitudeIdentitySync() { const { data: userProfile } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile, }) const currentWorkspace = useAtomValue(currentWorkspaceAtom) const lastIdentityRef = useRef(undefined) + const registrationSnapshot = useSyncExternalStore( + subscribeRegistrationSuccess, + getRegistrationSuccessSnapshot, + getRegistrationSuccessSnapshot, + ) useEffect(() => { if (!userProfile.id) return @@ -63,13 +72,14 @@ function AmplitudeIdentitySync() { properties, }) - if (identity === lastIdentityRef.current) return + if (identity !== lastIdentityRef.current) { + setUserId(userProfile.email) + setUserProperties(properties) + lastIdentityRef.current = identity + } - setUserId(userProfile.email) - setUserProperties(properties) - flushRegistrationSuccess() - lastIdentityRef.current = identity - }, [currentWorkspace, userProfile]) + void flushRegistrationSuccess() + }, [currentWorkspace, registrationSnapshot, userProfile]) return null } diff --git a/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..f89e3633885 --- /dev/null +++ b/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../document-title', () => ({ + default: () => marketplace document title, +})) + +describe('marketplace route layout', () => { + it('stays a server module so Flight can stream the marketplace page', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../layout.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/^['"]use client['"]/) + }) + + it('renders marketplace children and the document title island', async () => { + const { default: MarketplaceLayout } = await import('../layout') + + render( + +

marketplace page

+
, + ) + + expect(screen.getByText('marketplace document title')).toBeInTheDocument() + expect(screen.getByText('marketplace page')).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx new file mode 100644 index 00000000000..3f032667ccd --- /dev/null +++ b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/plugins/marketplace/marketplace-install-permission-provider', () => ({ + default: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/plugins/marketplace/embedded', () => ({ + EmbeddedMarketplace: () =>

Embedded marketplace home

, +})) + +vi.mock('@/app/components/main-nav/components/account-section', () => ({ + default: () => null, +})) + +describe('embedded marketplace home route', () => { + it('does not stream async server children that Flight would double-resolve', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/const MarketplacePage = async/) + expect(source).not.toContain('HydrateQueryClient') + }) + + it('renders the client marketplace home inside the install-permission provider', async () => { + const { default: MarketplacePage } = await import('../page') + render() + + const permission = screen.getByRole('region', { name: 'install permission' }) + + expect(permission).toContainElement(screen.getByText('Embedded marketplace home')) + }) +}) diff --git a/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx new file mode 100644 index 00000000000..1c9c5615aa6 --- /dev/null +++ b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx @@ -0,0 +1,51 @@ +import { loadCreatorProfile } from '@/app/components/plugins/marketplace/creator-profile/data.server' +import DifyCreatorProfile from '@/app/components/plugins/marketplace/creator-profile/dify-profile' +import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' +import { getLocaleOnServer } from '@/i18n-config/server' +import { notFound } from '@/next/navigation' + +type CreatorPageSearchParams = { + publisher_type?: string + sort_by?: string + sort_order?: string +} + +type CreatorProfilePageProps = { + params: Promise<{ uniqueHandle: string }> + searchParams: Promise +} + +// Sync route: async pages under this client shell Flight-double-resolve. +export default function CreatorProfilePage(props: CreatorProfilePageProps) { + return ( +
+ +
+ ) +} + +async function CreatorProfileContent({ params, searchParams }: CreatorProfilePageProps) { + const [{ uniqueHandle }, query, locale] = await Promise.all([ + params, + searchParams, + getLocaleOnServer(), + ]) + const loadedProfile = await loadCreatorProfile({ + uniqueHandle, + publisherType: query.publisher_type, + locale, + sortBy: query.sort_by, + sortOrder: query.sort_order, + }) + + if (!loadedProfile) notFound() + + return ( + + + + ) +} diff --git a/web/app/(commonLayout)/marketplace/document-title.tsx b/web/app/(commonLayout)/marketplace/document-title.tsx new file mode 100644 index 00000000000..d053ab9cb3c --- /dev/null +++ b/web/app/(commonLayout)/marketplace/document-title.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import useDocumentTitle from '@/hooks/use-document-title' + +const MarketplaceDocumentTitle = () => { + const { t } = useTranslation() + useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) + return null +} + +export default MarketplaceDocumentTitle diff --git a/web/app/(commonLayout)/marketplace/layout.tsx b/web/app/(commonLayout)/marketplace/layout.tsx index 5a40a6a3a62..7bf5569a955 100644 --- a/web/app/(commonLayout)/marketplace/layout.tsx +++ b/web/app/(commonLayout)/marketplace/layout.tsx @@ -1,12 +1,12 @@ -'use client' - import type { PropsWithChildren } from 'react' -import { useTranslation } from 'react-i18next' -import useDocumentTitle from '@/hooks/use-document-title' +import MarketplaceDocumentTitle from './document-title' +// Server layout: a client layout here Flight-double-resolves the page. export default function MarketplaceLayout({ children }: PropsWithChildren) { - const { t } = useTranslation() - useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) - - return children + return ( + <> + + {children} + + ) } diff --git a/web/app/(commonLayout)/marketplace/page.tsx b/web/app/(commonLayout)/marketplace/page.tsx index 9f56a38d4a9..7be84924b58 100644 --- a/web/app/(commonLayout)/marketplace/page.tsx +++ b/web/app/(commonLayout)/marketplace/page.tsx @@ -1,19 +1,25 @@ -import type { SearchParams } from 'nuqs' -import Marketplace from '@/app/components/plugins/marketplace' +import AccountSection from '@/app/components/main-nav/components/account-section' +import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants' +import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded' import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' -type MarketplacePageProps = { - searchParams?: Promise -} - -const MarketplacePage = ({ searchParams }: MarketplacePageProps) => { +// Sync route: async pages under this client shell Flight-double-resolve. +const MarketplacePage = () => { return (
- + + +
+ } + /> ) diff --git a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx new file mode 100644 index 00000000000..6c86517898b --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx @@ -0,0 +1,151 @@ +import type { FunctionComponent, ReactElement } from 'react' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { redirect } from '@/next/navigation' +import TemplatesPage from '../page' + +type TemplatesPageProps = Parameters[0] + +const resolveTemplatesPage = async (props: TemplatesPageProps) => { + const tree = TemplatesPage(props) as ReactElement<{ + children: ReactElement + className: string + id: string + }> + const child = tree.props.children + const content = await (child.type as FunctionComponent)(child.props) + return createElement(tree.type, tree.props, content) +} + +vi.mock('@/app/components/plugins/marketplace/templates', () => ({ + EmbeddedTemplatesMarketplace: ({ + category, + page, + query, + sortBy, + sortOrder, + view, + }: { + category: string + page: number + query: string + sortBy?: string + sortOrder?: string + view?: string + }) => ( +
+ {`Templates catalog: ${category}:${query}`} +
+ ), +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: () => Promise.resolve('en-US'), +})) + +vi.mock('@/next/navigation', () => ({ + redirect: vi.fn((path: string) => { + throw new Error(`redirect:${path}`) + }), +})) + +describe('embedded templates route', () => { + it('does not stream async server children that Flight would double-resolve', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/export default async function TemplatesPage/) + expect(TemplatesPage.constructor.name).not.toBe('AsyncFunction') + }) + + it('renders the templates catalog at /templates', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ q: 'agent' }), + }) + + render(page) + + expect(screen.getByText('Templates catalog: all:agent')).toBeInTheDocument() + expect(screen.getByText('Templates catalog: all:agent').parentElement).toHaveAttribute( + 'id', + 'marketplace-container', + ) + }) + + it('passes a supported path category to the templates catalog', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({ category: ['marketing'] }), + searchParams: Promise.resolve({}), + }) + + render(page) + + expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument() + }) + + it('validates page, view and sort params at the route boundary', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ + page: '3', + q: 'agent', + sort_by: 'created_at', + sort_order: 'ASC', + view: 'search', + }), + }) + + render(page) + + const catalog = screen.getByTestId('catalog') + expect(catalog).toHaveAttribute('data-page', '3') + expect(catalog).toHaveAttribute('data-sort-by', 'created_at') + expect(catalog).toHaveAttribute('data-sort-order', 'ASC') + expect(catalog).toHaveAttribute('data-view', 'search') + }) + + it('falls back to defaults for unsupported page, view and sort params', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ + page: '-2', + q: 'agent', + sort_by: 'garbage', + sort_order: 'sideways', + view: 'iframe', + }), + }) + + render(page) + + const catalog = screen.getByTestId('catalog') + expect(catalog).toHaveAttribute('data-page', '1') + expect(catalog).not.toHaveAttribute('data-sort-by') + expect(catalog).not.toHaveAttribute('data-sort-order') + expect(catalog).not.toHaveAttribute('data-view') + }) + + it('opens template recommendations in the existing Dify import flow', async () => { + await expect( + resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ tid: 'template/one' }), + }), + ).rejects.toThrow('redirect:/apps?template-id=template%2Fone') + + expect(redirect).toHaveBeenCalledWith('/apps?template-id=template%2Fone') + }) +}) diff --git a/web/app/(commonLayout)/templates/[[...category]]/page.tsx b/web/app/(commonLayout)/templates/[[...category]]/page.tsx new file mode 100644 index 00000000000..4d31ad4a2e0 --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/page.tsx @@ -0,0 +1,78 @@ +import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants' +import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates' +import { isTemplateCategory } from '@/app/components/plugins/marketplace/templates/categories' +import { getLocaleOnServer } from '@/i18n-config/server' +import { redirect } from '@/next/navigation' + +type TemplatesPageProps = { + params: Promise<{ category?: string[] }> + searchParams: Promise<{ + languages?: string | string[] + page?: string + q?: string + sort_by?: string + sort_order?: string + tid?: string + view?: string + }> +} + +// These values arrive from a public URL, so validate them against the +// supported enums here at the route boundary. Unknown values fall back to the +// defaults instead of reaching the Marketplace API, where e.g. +// `sort_order=garbage` fails and would surface as a false "no templates" state. +const TEMPLATE_SORT_FIELDS = new Set(['usage_count', 'created_at']) +const TEMPLATE_SORT_ORDERS = new Set(['ASC', 'DESC']) + +const parseView = (value?: string) => (value === 'search' ? 'search' : undefined) + +const parseSortBy = (value?: string) => + value && TEMPLATE_SORT_FIELDS.has(value) ? value : undefined + +const parseSortOrder = (value?: string) => + value && TEMPLATE_SORT_ORDERS.has(value) ? value : undefined + +const parsePage = (value?: string) => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1 +} + +// Sync route: async pages under this client shell Flight-double-resolve. +export default function TemplatesPage(props: TemplatesPageProps) { + return ( +
+ +
+ ) +} + +async function TemplatesPageContent({ params, searchParams }: TemplatesPageProps) { + const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([ + params, + searchParams, + getLocaleOnServer(), + ]) + + if (resolvedSearchParams.tid) { + redirect(`/apps?template-id=${encodeURIComponent(resolvedSearchParams.tid)}`) + } + + const requestedCategory = resolvedParams.category?.[0] + const category = isTemplateCategory(requestedCategory) ? requestedCategory : 'all' + + return ( + + ) +} diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx index ea56e750817..e2dc1b9d4ef 100644 --- a/web/app/__tests__/layout.spec.tsx +++ b/web/app/__tests__/layout.spec.tsx @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { QueryClient } from '@tanstack/react-query' let queryClient: QueryClient @@ -130,4 +133,16 @@ describe('Root layout System Features bootstrap', () => { expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined() }) + + it('does not inject marketplace PWA chrome or a global ResizeObserver filter', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../layout.tsx'), + 'utf8', + ) + + expect(source).not.toContain('manifest.json') + expect(source).not.toContain('apple-touch-icon') + expect(source).not.toContain('browserconfig.xml') + expect(source).not.toContain('ResizeObserver') + }) }) diff --git a/web/app/account/oauth/authorize/__tests__/page.spec.tsx b/web/app/account/oauth/authorize/__tests__/page.spec.tsx index a06d4499e46..31bba9a90e8 100644 --- a/web/app/account/oauth/authorize/__tests__/page.spec.tsx +++ b/web/app/account/oauth/authorize/__tests__/page.spec.tsx @@ -127,6 +127,24 @@ describe('OAuthAuthorize', () => { ) }) + it('preserves an encoded redirect URI when requesting the OAuth app', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?next=%2Fplugins', + state: 'state-1', + }) + + renderPage() + + expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0) + const providerRequest = findRequest('/oauth/provider') + const providerTransportRequest = providerRequest?.[2]?.request as Request + await expect(providerTransportRequest.clone().json()).resolves.toEqual({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?next=%2Fplugins', + }) + }) + it('silently authorizes an app flagged with auto_authorize without rendering consent', async () => { mocks.searchParams = new URLSearchParams({ client_id: 'marketplace-client', @@ -177,6 +195,56 @@ describe('OAuthAuthorize', () => { expect(findRequest('/oauth/provider/authorize')).toBeUndefined() }) + it('does not auto-authorize with incomplete OAuth parameters', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'marketplace-client', + }) + mockProviderResponses({ autoAuthorize: true }) + + renderPage() + + expect(await screen.findByText('oauth.error.invalidParams')).toBeInTheDocument() + expect(findRequest('/oauth/provider')).toBeUndefined() + expect(findRequest('/oauth/provider/authorize')).toBeUndefined() + }) + + it('retries app info loading and resumes auto-authorization', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'marketplace-client', + redirect_uri: 'https://api.marketplace.example.com/api/v1/auth/callback/dify', + response_type: 'code', + state: 'marketplace-state', + }) + let providerAttempts = 0 + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider/authorize')) return jsonResponse({ code: 'oauth-code' }) + if (url.endsWith('/oauth/provider')) { + providerAttempts += 1 + if (providerAttempts === 1) throw new Error('Failed to load OAuth app') + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + auto_authorize: true, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + const user = userEvent.setup() + renderPage() + + expect(await screen.findByText('oauth.error.authAppInfoFetchFailed')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + await waitFor(() => expect(findRequest('/oauth/provider/authorize')).toBeDefined()) + await waitFor(() => + expect(globalThis.location.href).toBe( + 'https://api.marketplace.example.com/api/v1/auth/callback/dify?code=oauth-code&state=marketplace-state', + ), + ) + }) + it('falls back to manual confirmation when silent authorization fails', async () => { mocks.searchParams = new URLSearchParams({ client_id: 'marketplace-client', @@ -215,4 +283,38 @@ describe('OAuthAuthorize', () => { ), ) }) + + it('renders an unknown OAuth scope without crashing', async () => { + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + scope: 'read:custom_profile', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + renderPage() + + expect(await screen.findByText('read:custom_profile')).toBeInTheDocument() + }) + + it('supports OAuth app labels that use a hyphenated locale key', async () => { + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { 'en-US': 'Hyphenated OAuth App' }, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + renderPage() + + expect((await screen.findAllByText('Hyphenated OAuth App')).length).toBeGreaterThan(0) + }) }) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 28d54aa82f0..a8924817e71 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -13,7 +13,6 @@ import { } from '@remixicon/react' import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import * as React from 'react' -import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -57,10 +56,10 @@ export default function OAuthAuthorize() { const router = useRouter() const language = useLanguage() const searchParams = useSearchParams() - const client_id = decodeURIComponent(searchParams.get('client_id') || '') - const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') + const clientId = searchParams.get('client_id') || '' + const redirectUri = searchParams.get('redirect_uri') || '' const state = searchParams.get('state') - const hasOAuthParams = Boolean(client_id && redirect_uri) + const hasOAuthParams = Boolean(clientId && redirectUri) // Probe user profile. 401 stays as `error` (legitimate "not logged in" state), // other errors throw to the nearest error.tsx; jumpTo same-pathname guard in // service/base.ts prevents a redirect loop here. @@ -77,10 +76,14 @@ export default function OAuthAuthorize() { const { data: authAppInfo, isLoading: isOAuthLoading, - isError, + isFetching: isOAuthFetching, + isError: isOAuthError, + refetch: refetchOAuthApp, } = useQuery( consoleQuery.oauth.provider.post.queryOptions({ - input: hasOAuthParams ? { body: { client_id, redirect_uri } } : skipToken, + input: hasOAuthParams + ? { body: { client_id: clientId, redirect_uri: redirectUri } } + : skipToken, context: { silent: true }, }), ) @@ -91,17 +94,17 @@ export default function OAuthAuthorize() { const { isAutoAuthorizing } = useSilentAuthorize({ authAppInfo, authorize, - clientId: client_id, + clientId, hasOAuthParams, isLoggedIn, isProfileLoading, - redirectUri: redirect_uri, + redirectUri, searchParams, state, }) - const hasNotifiedRef = useRef(false) - const localizedAppLabel = authAppInfo?.app_label[language] - const englishAppLabel = authAppInfo?.app_label.en_US + const localizedAppLabel = + authAppInfo?.app_label[language] ?? authAppInfo?.app_label[language.replace('_', '-')] + const englishAppLabel = authAppInfo?.app_label.en_US ?? authAppInfo?.app_label['en-US'] const appLabel = (typeof localizedAppLabel === 'string' && localizedAppLabel) || (typeof englishAppLabel === 'string' && englishAppLabel) || @@ -112,7 +115,6 @@ export default function OAuthAuthorize() { : t(($) => $.connect, { ns: 'oauth' }), ) - const isLoading = isOAuthLoading || isProfileLoading const onLoginSwitchClick = async () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`) @@ -124,30 +126,39 @@ export default function OAuthAuthorize() { } const onAuthorize = async () => { - if (!client_id || !redirect_uri) return + if (!clientId || !redirectUri) return try { - const { code } = await authorize({ body: { client_id } }) - globalThis.location.href = buildOAuthCallbackUrl(redirect_uri, code, state) + const { code } = await authorize({ body: { client_id: clientId } }) + globalThis.location.href = buildOAuthCallbackUrl(redirectUri, code, state) } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error) toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`) } } - useEffect(() => { - const invalidParams = !client_id || !redirect_uri - if ((invalidParams || isError) && !hasNotifiedRef.current) { - hasNotifiedRef.current = true - toast.error( - invalidParams - ? t(($) => $['error.invalidParams'], { ns: 'oauth' }) - : t(($) => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }), - { timeout: 0 }, - ) - } - }, [client_id, redirect_uri, isError]) + if (!hasOAuthParams || isOAuthError) { + return ( +
+
+ {t(($) => $[hasOAuthParams ? 'error.authAppInfoFetchFailed' : 'error.invalidParams'], { + ns: 'oauth', + })} +
+ {isOAuthError && ( + + )} +
+ ) + } - if (isLoading || isAutoAuthorizing) { + if (isProfileLoading || isOAuthLoading || isAutoAuthorizing) { return (
@@ -203,18 +214,15 @@ export default function OAuthAuthorize() { .split(/\s+/) .filter(Boolean) .map((scope: string) => { - const Icon = SCOPE_INFO_MAP[scope] + const scopeInfo = SCOPE_INFO_MAP[scope] + const ScopeIcon = scopeInfo?.icon ?? RiAccountCircleLine return (
- {Icon ? ( - - ) : ( - - )} - {Icon!.label} + + {scopeInfo?.label ?? scope}
) })} @@ -238,7 +246,7 @@ export default function OAuthAuthorize() { size="large" className="w-full" onClick={onAuthorize} - disabled={!client_id || !redirect_uri || isError || authorizing} + disabled={!clientId || !redirectUri || isOAuthError || authorizing} loading={authorizing} > {t(($) => $.continue, { ns: 'oauth' })} diff --git a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx index eb8f7deacdf..53d18f0d2b9 100644 --- a/web/app/components/__tests__/oauth-registration-analytics.spec.tsx +++ b/web/app/components/__tests__/oauth-registration-analytics.spec.tsx @@ -1,12 +1,31 @@ import { render, waitFor } from '@testing-library/react' import Cookies from 'js-cookie' +import { StrictMode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useSearchParams } from '@/next/navigation' import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics' -const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({ - mockSendGAEvent: vi.fn(), +const { + mockConsent, + mockNormalizeRegistrationAttribution, + mockRememberRegistrationSuccess, + mockSendGAEvent, +} = vi.hoisted(() => ({ + mockConsent: { value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled' }, + mockNormalizeRegistrationAttribution: vi.fn((value: Record | null) => { + if (!value) return null + const allowed = Object.fromEntries( + Object.entries(value).filter( + ([key, item]) => + ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'].includes( + key, + ) && typeof item === 'string', + ), + ) + return Object.keys(allowed).length ? allowed : null + }), mockRememberRegistrationSuccess: vi.fn(), + mockSendGAEvent: vi.fn(), })) vi.mock('@/utils/gtag', () => ({ @@ -17,7 +36,14 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) +vi.mock('../base/analytics-consent/consent-store', () => ({ + useAnalyticsConsent: () => mockConsent.value, +})) + vi.mock('../base/amplitude/registration-tracking', () => ({ + normalizeRegistrationAttribution: ( + ...args: Parameters + ) => mockNormalizeRegistrationAttribution(...args), rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args), })) @@ -33,22 +59,74 @@ const setSearchParams = (searchParams = '') => { describe('OAuthRegistrationAnalytics', () => { beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.clear() + mockConsent.value = 'granted' + mockRememberRegistrationSuccess.mockReturnValue(true) Cookies.remove('utm_info') vi.spyOn(console, 'error').mockImplementation(() => {}) setSearchParams() }) - it('should track oauth registration with utm info and clear the query flag', async () => { + it('queues the Amplitude marker while consent is unknown and cleans the URL after persist', async () => { + mockConsent.value = 'unknown' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch' })) + setSearchParams('oauth_new_user=true&source=signin') + + render() + + await waitFor(() => { + expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({ + method: 'oauth', + utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' }, + }) + }) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + expect(Cookies.get('utm_info')).toBeUndefined() + expect(window.location.search).toBe('?source=signin') + }) + + it('keeps the recoverable OAuth signal when marker persistence fails', () => { + Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin' })) + setSearchParams('oauth_new_user=true&source=signin') + mockRememberRegistrationSuccess.mockReturnValue(false) + + render() + + expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1) + expect(window.location.search).toBe('?oauth_new_user=true&source=signin') + expect(Cookies.get('utm_info')).toBeTruthy() + }) + + it('keeps the OAuth marker while consent is unknown, then cleans without a second Amplitude queue on denial', async () => { + mockConsent.value = 'unknown' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' })) + setSearchParams('oauth_new_user=true') + + const { rerender } = render() + + await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + + mockConsent.value = 'denied' + rerender() + + await waitFor(() => expect(window.location.search).toBe('')) + expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('queues immediately with pre-granted consent and keeps only allowlisted UTM fields', async () => { Cookies.set( 'utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch', + arbitrary: 'discard-me', + utm_term: { nested: true }, }), ) - setSearchParams('oauth_new_user=true&source=signin') - const replaceStateSpy = vi.spyOn(window.history, 'replaceState') render() @@ -64,16 +142,13 @@ describe('OAuthRegistrationAnalytics', () => { slug: 'agent-launch', }) expect(Cookies.get('utm_info')).toBeUndefined() - - await waitFor(() => { - expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin') - }) + expect(window.location.search).toBe('?source=signin') }) - it('should fall back to the base registration event when the utm cookie is invalid', async () => { + it('uses the base event and cleans up when the UTM cookie is malformed', async () => { Cookies.set('utm_info', '{invalid-json') - setSearchParams('oauth_new_user=true') + render() await waitFor(() => { @@ -89,23 +164,77 @@ describe('OAuthRegistrationAnalytics', () => { expect(Cookies.get('utm_info')).toBeUndefined() }) - it('should do nothing without the oauth registration query flag', () => { + it('cleans a false OAuth marker immediately without tracking or clearing utm_info', async () => { + mockConsent.value = 'unknown' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' })) + setSearchParams('oauth_new_user=false') + + render() + + await waitFor(() => expect(window.location.search).toBe('')) + expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() + expect(mockSendGAEvent).not.toHaveBeenCalled() + expect(Cookies.get('utm_info')).toBe(JSON.stringify({ utm_source: 'blog' })) + }) + + it('tracks GA and Amplitude once across StrictMode effects and rerenders', async () => { + setSearchParams('oauth_new_user=true') + + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)) + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + }) + + it('tracks GA once across an unknown-consent remount that simulates reload', async () => { + mockConsent.value = 'unknown' + setSearchParams('oauth_new_user=true') + + const firstRender = render() + await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)) + firstRender.unmount() + render() + + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) + }) + + it('treats analytics-disabled consent as terminal and cleans without Amplitude', async () => { + mockConsent.value = 'disabled' + Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' })) + setSearchParams('oauth_new_user=true') + + render() + + await waitFor(() => expect(window.location.search).toBe('')) + expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('does nothing without the OAuth registration query marker', () => { render() expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() expect(mockSendGAEvent).not.toHaveBeenCalled() }) - it('should clear a false oauth registration query flag without tracking', async () => { - setSearchParams('oauth_new_user=false') - const replaceStateSpy = vi.spyOn(window.history, 'replaceState') + it('clears an abandoned flow guard so a later OAuth registration can emit GA', () => { + window.sessionStorage.setItem('oauth_registration_ga_sent', 'true') + const abandonedFlow = render() + abandonedFlow.unmount() + setSearchParams('oauth_new_user=true') render() - await waitFor(() => { - expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin') - }) - expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled() - expect(mockSendGAEvent).not.toHaveBeenCalled() + expect(mockSendGAEvent).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx index 8aa12ed5457..9c80ad25f11 100644 --- a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx @@ -295,6 +295,7 @@ function renderFlow( return render( ({ default: ({ @@ -314,6 +315,7 @@ describe('app-publisher sections', () => { description: 'Workflow description', }} appURL="https://example.com/app" + canAccessPoint disabledFunctionButton={false} disabledFunctionTooltip="disabled" handleOpenRunConfig={handleOpenRunConfig} @@ -494,6 +496,7 @@ describe('app-publisher sections', () => { mode: AppModeEnum.WORKFLOW, }} appURL="https://example.com/app" + canAccessPoint disabledFunctionButton={false} hasHumanInputNode={false} hasTriggerNode @@ -517,11 +520,49 @@ describe('app-publisher sections', () => { ) }) + it('should hide the built-in Access Point action without permission', () => { + render( + , + ) + + expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute( + 'href', + '/app/workflow-app/deploy', + ) + }) + + it('should hide the environment Access Point action without permission', () => { + render( + , + ) + + expect(screen.queryByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/)).not.toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/)).toBeInTheDocument() + }) + it('should expose unavailable quick links as disabled buttons before the first publish', () => { render( void @@ -41,6 +42,7 @@ type PublisherActionsSectionProps = Pick< export function PublisherActionsSection({ appDetail, appURL, + canAccessPoint = false, disabledFunctionButton, disabledFunctionTooltip, handleOpenRunConfig, @@ -114,14 +116,16 @@ export function PublisherActionsSection({ {disabledFunctionTooltip} )} - $['common.accessPointDescription'], { ns: 'workflow' })} - link={appId ? `/app/${appId}/access-point` : undefined} - icon={} - > - {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} - + {canAccessPoint && ( + $['common.accessPointDescription'], { ns: 'workflow' })} + link={appId ? `/app/${appId}/access-point` : undefined} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + + )} {showDeploy && ( - $['common.accessPointDescription'], { ns: 'workflow' })} - link={accessPointHref} - icon={} - > - {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} - + {canAccessPoint && ( + $['common.accessPointDescription'], { ns: 'workflow' })} + link={accessPointHref} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + + )} $['common.deployDescription'], { ns: 'workflow' })} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx index dce08832bc0..9f0b1201993 100644 --- a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx +++ b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx @@ -15,6 +15,7 @@ import { PublisherEnvironmentSummarySection } from './summary-section' type PublisherEnvironmentFlowProps = { appId?: string + canAccessPoint?: boolean deployment?: EnvironmentDeployment environmentId: string environmentName: string @@ -28,6 +29,7 @@ type PublisherEnvironmentFlowProps = { export function PublisherEnvironmentFlow({ appId, + canAccessPoint = false, deployment, environmentId, environmentName, @@ -91,6 +93,7 @@ export function PublisherEnvironmentFlow({ /> diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 48278b77cfc..38e9b724d1e 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -17,12 +17,13 @@ export function AppPublisher(props: AppPublisherProps) { select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canDeploy = getAppACLCapabilities(appDetail?.permission_keys, { + const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { currentUserId, resourceMaintainer: appDetail?.maintainer, workspacePermissionKeys, - }).canDeploy - const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy + }) + const supportsMultiEnvironment = + appDetail?.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy return ( void } export function PublisherContent({ + canAccessPoint, crossAxisOffset = 0, debugWithMultipleModel = false, disabled = false, @@ -212,6 +214,7 @@ export function PublisherContent({ actions: { appDetail, appURL, + canAccessPoint, disabledFunctionButton, disabledFunctionTooltip, handleOpenRunConfig: workflowLaunch.openDialog, @@ -236,6 +239,7 @@ export function PublisherContent({ disabled={disabled} environmentPublisher={{ appId: appDetail?.id, + canAccessPoint, deployment: selectedEnvironmentDeployment, environmentId: selectedEnvironmentId, environmentName: diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 11888a09817..79c1f082294 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -650,7 +650,7 @@ function render( return renderWithConsoleQuery(ui, { queryClient }) } -let appPermissionKeys: string[] = [AppACLPermission.Deploy] +let appPermissionKeys: string[] = [AppACLPermission.AccessPoint, AppACLPermission.Deploy] let appDetailAvailable = true const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], @@ -744,7 +744,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ describe('AppDeploy', () => { beforeEach(() => { vi.clearAllMocks() - appPermissionKeys = [AppACLPermission.Deploy] + appPermissionKeys = [AppACLPermission.AccessPoint, AppACLPermission.Deploy] appDetailAvailable = true mockBuiltInEnvironment.appDetail.enable_api = false mockBuiltInEnvironment.appDetail.enable_site = true @@ -815,6 +815,18 @@ describe('AppDeploy', () => { ).toHaveAttribute('href', '/app/app-1/access-point?environment=canary&accessPoint=serviceApi') }) + it('keeps active access points non-navigable without access point permission', () => { + appPermissionKeys = [AppACLPermission.Deploy] + + render() + + const canaryRow = within(screen.getByRole('row', { name: /Canary/ })) + const webAppLabel = + 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService' + expect(canaryRow.queryByRole('link', { name: webAppLabel })).not.toBeInTheDocument() + expect(canaryRow.getByRole('button', { name: webAppLabel })).toBeDisabled() + }) + it('renders the built-in version, access points, and publisher from live app data', () => { render() diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx index d04dce259a8..607250e7ce6 100644 --- a/web/app/components/app/deploy/built-in-environment-card/index.tsx +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -19,7 +19,7 @@ function Divider() { return
} -export function BuiltInEnvironmentCard() { +export function BuiltInEnvironmentCard({ canAccessPoint = false }: { canAccessPoint?: boolean }) { const { t } = useTranslation('deployments') const { formatTime } = useTimestamp() const appDetail = useAppStore((state) => state.appDetail) @@ -90,7 +90,9 @@ export function BuiltInEnvironmentCard() { key={accessPoint} accessPoint={accessPoint} active={activeAccessPoints[accessPoint]} - href={getAccessPointHref(appId, 'built-in', accessPoint)} + href={ + canAccessPoint ? getAccessPointHref(appId, 'built-in', accessPoint) : undefined + } /> ))}
diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx index cbee27225ed..cc9b9451e59 100644 --- a/web/app/components/app/deploy/environment-table/index.tsx +++ b/web/app/components/app/deploy/environment-table/index.tsx @@ -23,6 +23,7 @@ import { EnvironmentRow } from './row' type EnvironmentTableProps = { appId: string + canAccessPoint?: boolean onChangeVersion?: (deployment: EnvironmentDeployment) => void onDeployLatest?: (deployment: EnvironmentDeployment) => void onDeployToEnvironment?: (environment: AppEnvironment) => void @@ -32,6 +33,7 @@ type EnvironmentTableProps = { export function EnvironmentTable({ appId, + canAccessPoint = false, onChangeVersion, onDeployLatest, onDeployToEnvironment, @@ -132,6 +134,7 @@ export function EnvironmentTable({ void onDeployLatest?: (deployment: EnvironmentDeployment) => void @@ -60,7 +62,11 @@ export function EnvironmentRow({ key={accessPoint} accessPoint={accessPoint} active={isAccessPointActive(accessPoint)} - href={getAccessPointHref(appId, row.environment.id, accessPoint)} + href={ + canAccessPoint + ? getAccessPointHref(appId, row.environment.id, accessPoint) + : undefined + } /> ))}
diff --git a/web/app/components/app/deploy/index.tsx b/web/app/components/app/deploy/index.tsx index c22034584dd..4f1bee0ed1e 100644 --- a/web/app/components/app/deploy/index.tsx +++ b/web/app/components/app/deploy/index.tsx @@ -22,7 +22,7 @@ import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './use-refresh-a import { useUndeployWorkflow } from './use-undeploy-workflow' import { toDeploymentVersion } from './version' -function AppDeployContent({ appId }: { appId: string }) { +function AppDeployContent({ appId, canAccessPoint }: { appId: string; canAccessPoint: boolean }) { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation('common') const { t: tWorkflow } = useTranslation('workflow') @@ -86,9 +86,10 @@ function AppDeployContent({ appId }: { appId: string }) {
- + setDeploymentRequest({ environment: environment.display_name, @@ -139,17 +140,17 @@ export default function AppDeploy() { if (!appDetail) return - const canDeploy = getAppACLCapabilities(appDetail.permission_keys, { + const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, workspacePermissionKeys, - }).canDeploy + }) - if (appDetail.mode !== AppModeEnum.WORKFLOW || !canDeploy) return null + if (appDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy) return null return ( - + ) } diff --git a/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx b/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx new file mode 100644 index 00000000000..80d18eaaee6 --- /dev/null +++ b/web/app/components/app/deploy/shared/__tests__/access-point-icon.spec.tsx @@ -0,0 +1,35 @@ +import { screen } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { AccessPointIcon } from '../access-point-icon' + +describe('AccessPointIcon', () => { + it('links active access points when navigation is allowed', () => { + render( + , + ) + + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/app/app-1/access-point?environment=built-in&accessPoint=webApp', + ) + }) + + it('keeps active access points visually active when navigation is not allowed', () => { + render() + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByRole('button')).toBeDisabled() + expect(screen.getByRole('button')).not.toHaveClass('opacity-30') + }) + + it('dims inactive access points', () => { + render() + + expect(screen.getByRole('button')).toBeDisabled() + expect(screen.getByRole('button')).toHaveClass('opacity-30') + }) +}) diff --git a/web/app/components/app/deploy/shared/access-point-icon.tsx b/web/app/components/app/deploy/shared/access-point-icon.tsx index 734e9b694ee..253429f03fc 100644 --- a/web/app/components/app/deploy/shared/access-point-icon.tsx +++ b/web/app/components/app/deploy/shared/access-point-icon.tsx @@ -32,7 +32,7 @@ export function AccessPointIcon({ }: { active: boolean accessPoint: AccessPoint - href: string + href?: string }) { const { t } = useTranslation('agentV2') const labels = useAccessPointLabels() @@ -40,9 +40,11 @@ export function AccessPointIcon({ ? t(($) => $['agentDetail.access.status.inService']) : t(($) => $['agentDetail.access.status.outOfService']) const label = `${labels[accessPoint]} · ${status}` + const canNavigate = active && Boolean(href) const triggerClassName = cn( 'flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-regular text-text-secondary shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', - active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-30', + active && (canNavigate ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-default'), + !active && 'cursor-not-allowed opacity-30', ) const icon = ( @@ -52,7 +54,7 @@ export function AccessPointIcon({ {icon} diff --git a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts index 30c6707a702..e5979a79623 100644 --- a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts +++ b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts @@ -1,55 +1,182 @@ import { - flushRegistrationSuccess, + discardRegistrationSessionState, REGISTRATION_SUCCESS_STORAGE_KEY, +} from '../registration-session-state' +import { + coordinateRegistrationConsent, + flushRegistrationSuccess, rememberRegistrationSuccess, + subscribeRegistrationSuccess, } from '../registration-tracking' const mockTrackEvent = vi.hoisted(() => vi.fn()) +const mockAmplitudeInitialized = vi.hoisted(() => ({ value: true })) const mockConsent = vi.hoisted(() => ({ - value: 'granted' as 'unknown' | 'denied' | 'granted', + value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled', })) vi.mock('../utils', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })) +vi.mock('../init', () => ({ + getIsAmplitudeInitialized: () => mockAmplitudeInitialized.value, +})) + vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({ getAnalyticsConsent: () => mockConsent.value, })) +const successResult = () => ({ + promise: Promise.resolve({ code: 200 }), +}) + +const getStoredMarker = () => + JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!) + describe('registration tracking', () => { beforeEach(() => { vi.clearAllMocks() vi.unstubAllGlobals() + vi.useRealTimers() window.sessionStorage.clear() mockConsent.value = 'granted' + mockAmplitudeInitialized.value = true + mockTrackEvent.mockImplementation(successResult) + coordinateRegistrationConsent('denied') + mockConsent.value = 'granted' + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111', + ) }) - // Captures the registration event for a later flush instead of firing it right away. - describe('rememberRegistrationSuccess', () => { - it('should store the base event and not track immediately when there is no utm info', () => { - rememberRegistrationSuccess({ method: 'email' }) + afterEach(() => { + discardRegistrationSessionState() + vi.useRealTimers() + }) - expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({ - eventName: 'user_registration_success', - properties: { method: 'email' }, + describe('rememberRegistrationSuccess', () => { + it('stores a versioned marker with stable delivery metadata and allowlisted attribution', () => { + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + + const persisted = rememberRegistrationSuccess({ + method: 'email', + utmInfo: { + utm_source: 'linkedin', + utm_medium: 'social', + utm_campaign: 'launch', + utm_content: 'hero', + utm_term: 'agents', + slug: 'agent-launch', + unexpected: 'discard-me', + nested: { unsafe: true }, + }, + }) + + const occurredAt = Date.now() + expect(getStoredMarker()).toEqual({ + version: 2, + registrationId: '11111111-1111-4111-8111-111111111111', + occurredAt, + expiresAt: occurredAt + 24 * 60 * 60 * 1000, + eventName: 'user_registration_success_with_utm', + method: 'email', + attribution: { + utm_source: 'linkedin', + utm_medium: 'social', + utm_campaign: 'launch', + utm_content: 'hero', + utm_term: 'agents', + slug: 'agent-launch', + }, }) expect(mockTrackEvent).not.toHaveBeenCalled() + expect(persisted).toBe(true) }) - it('should store the utm event and merge utm info into properties when utm info is present', () => { - rememberRegistrationSuccess({ - method: 'oauth', - utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' }, + it('persists the latest email marker while consent is unknown so a later grant can flush it', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'first' } }) + rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'latest' } }) + + expect(getStoredMarker()).toMatchObject({ + version: 2, + method: 'email', + attribution: { utm_source: 'latest' }, }) - expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({ - eventName: 'user_registration_success_with_utm', - properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' }, - }) + await flushRegistrationSuccess() + expect(mockTrackEvent).not.toHaveBeenCalled() + + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it('should swallow errors when writing to sessionStorage fails', () => { + it('discards an unknown-consent marker on denial before a later grant can flush it', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'email' }) + + coordinateRegistrationConsent('denied') + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it('discards a pending marker and GA guard at an account boundary', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'email' }) + window.sessionStorage.setItem('oauth_registration_ga_sent', 'true') + + discardRegistrationSessionState() + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem('oauth_registration_ga_sent')).toBeNull() + }) + + it.each(['denied', 'disabled'] as const)( + 'discards a stored marker when consent changes to %s', + (consent) => { + rememberRegistrationSuccess({ method: 'email' }) + + coordinateRegistrationConsent(consent) + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }, + ) + + it('persists an oauth marker while consent is unknown so a reload can still flush it', async () => { + mockConsent.value = 'unknown' + rememberRegistrationSuccess({ method: 'oauth' }) + + expect(getStoredMarker()).toMatchObject({ method: 'oauth' }) + + mockConsent.value = 'granted' + await flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + }) + + it('notifies consumers only after a marker is stored', () => { + const listener = vi.fn() + const unsubscribe = subscribeRegistrationSuccess(listener) + + rememberRegistrationSuccess({ method: 'email' }) + + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('swallows sessionStorage write errors without notifying consumers', () => { + const listener = vi.fn() + const unsubscribe = subscribeRegistrationSuccess(listener) vi.stubGlobal('window', { sessionStorage: { getItem: vi.fn(() => null), @@ -60,158 +187,365 @@ describe('registration tracking', () => { }, }) - try { - expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() - } finally { - vi.unstubAllGlobals() - } + expect(rememberRegistrationSuccess({ method: 'email' })).toBe(false) + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) + }) + + describe('flushRegistrationSuccess', () => { + it('waits for a successful SDK result before acknowledging the marker', async () => { + let resolveTrack!: (result: { code: number }) => void + mockTrackEvent.mockReturnValue({ + promise: new Promise((resolve) => { + resolveTrack = resolve + }), + }) + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + rememberRegistrationSuccess({ method: 'oauth', utmInfo: { utm_source: 'blog' } }) + + const flushPromise = flushRegistrationSuccess() + + expect(getStoredMarker()).toBeTruthy() + expect(mockTrackEvent).toHaveBeenCalledWith( + 'user_registration_success_with_utm', + { + method: 'oauth', + utm_source: 'blog', + registration_id: '11111111-1111-4111-8111-111111111111', + event_version: 2, + tracking_contract_version: 'consent_wait_v2', + }, + { + insert_id: '11111111-1111-4111-8111-111111111111', + time: Date.now(), + }, + ) + + resolveTrack({ code: 200 }) + await flushPromise + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it.each(['unknown', 'denied'] as const)( - 'should not cache an event while consent is %s', - (consent) => { - mockConsent.value = consent + it.each([ + ['unknown consent', () => (mockConsent.value = 'unknown')], + ['uninitialized Amplitude', () => (mockAmplitudeInitialized.value = false)], + ])('defers without deleting for %s', async (_label, makeIneligible) => { + rememberRegistrationSuccess({ method: 'email' }) + makeIneligible() + + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(getStoredMarker()).toBeTruthy() + }) + + it('discards a pending marker when consent is denied', async () => { + rememberRegistrationSuccess({ method: 'oauth' }) + mockConsent.value = 'denied' + + await flushRegistrationSuccess() + + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it('retains the marker on SDK rejection or a non-success result and reuses its id and time', async () => { + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + rememberRegistrationSuccess({ method: 'email' }) + const marker = getStoredMarker() + mockTrackEvent + .mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) }) + .mockReturnValueOnce({ promise: Promise.resolve({ code: 500 }) }) + .mockImplementation(successResult) + + await flushRegistrationSuccess() + await flushRegistrationSuccess() + + expect(getStoredMarker()).toEqual(marker) + expect(mockTrackEvent.mock.calls[0]?.[2]).toEqual({ + insert_id: marker.registrationId, + time: marker.occurredAt, + }) + expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({ + insert_id: marker.registrationId, + time: marker.occurredAt, + }) + }) + + it.each(['rejected acknowledgement', 'non-success acknowledgement'] as const)( + 'continues with a replacement marker after a %s', + async (oldAcknowledgement) => { + const firstRegistrationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const replacementRegistrationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce(firstRegistrationId) + .mockReturnValueOnce(replacementRegistrationId) + let resolveOldAcknowledgement!: (result: { code: number }) => void + let rejectOldAcknowledgement!: (error: Error) => void + const pendingOldAcknowledgement = new Promise<{ code: number }>((resolve, reject) => { + resolveOldAcknowledgement = resolve + rejectOldAcknowledgement = reject + }) + mockTrackEvent + .mockReturnValueOnce({ promise: pendingOldAcknowledgement }) + .mockImplementation(successResult) rememberRegistrationSuccess({ method: 'email' }) + const firstFlush = flushRegistrationSuccess() + rememberRegistrationSuccess({ method: 'email' }) + const replacementFlush = flushRegistrationSuccess() + expect(replacementFlush).toBe(firstFlush) + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + + if (oldAcknowledgement === 'rejected acknowledgement') + rejectOldAcknowledgement(new Error('network failed')) + else resolveOldAcknowledgement({ code: 500 }) + await firstFlush + + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({ + insert_id: replacementRegistrationId, + time: expect.any(Number), + }) expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }, ) - }) - // Replays the remembered event exactly once, after the user ID has been attached. - describe('flushRegistrationSuccess', () => { - it('should track the remembered event and clear it from storage', () => { - rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } }) - - flushRegistrationSuccess() + it('retries an acknowledgement-failed marker after the backoff delay', async () => { + vi.useFakeTimers() + rememberRegistrationSuccess({ method: 'email' }) + const marker = getStoredMarker() + mockTrackEvent + .mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) }) + .mockImplementation(successResult) + await flushRegistrationSuccess() + expect(getStoredMarker()).toEqual(marker) expect(mockTrackEvent).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', { - method: 'email', - utm_source: 'blog', + + await vi.advanceTimersByTimeAsync(1000) + + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls[1]?.[2]).toEqual({ + insert_id: marker.registrationId, + time: marker.occurredAt, }) expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it('should do nothing when there is no pending event', () => { - flushRegistrationSuccess() + it('does not retry an acknowledgement-failed marker after an account boundary', async () => { + rememberRegistrationSuccess({ method: 'email' }) + mockTrackEvent.mockReturnValueOnce({ promise: Promise.reject(new Error('network failed')) }) + await flushRegistrationSuccess() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).not.toBeNull() + + discardRegistrationSessionState() + await flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it.each([ + ['session discard', 'resolves'] as const, + ['session discard', 'rejects'] as const, + ['denied consent', 'resolves'] as const, + ['disabled analytics', 'resolves'] as const, + ])( + 'isolates a new registration flush after %s while the old SDK acknowledgement %s', + async (invalidation, oldAcknowledgement) => { + const accountARegistrationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const accountBRegistrationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce(accountARegistrationId) + .mockReturnValueOnce(accountBRegistrationId) + + let resolveAccountA!: (result: { code: number }) => void + let rejectAccountA!: (error: Error) => void + let resolveAccountB!: (result: { code: number }) => void + const accountAAcknowledgement = new Promise<{ code: number }>((resolve, reject) => { + resolveAccountA = resolve + rejectAccountA = reject + }) + const accountBAcknowledgement = new Promise<{ code: number }>((resolve) => { + resolveAccountB = resolve + }) + mockTrackEvent + .mockReturnValueOnce({ promise: accountAAcknowledgement }) + .mockReturnValueOnce({ promise: accountBAcknowledgement }) + + rememberRegistrationSuccess({ method: 'email' }) + const accountAFlush = flushRegistrationSuccess() + + if (invalidation === 'session discard') { + discardRegistrationSessionState() + } else { + const terminalConsent = invalidation === 'denied consent' ? 'denied' : 'disabled' + mockConsent.value = terminalConsent + coordinateRegistrationConsent(terminalConsent) + mockConsent.value = 'granted' + } + + rememberRegistrationSuccess({ method: 'email' }) + const accountBMarker = window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + const accountBFlush = flushRegistrationSuccess() + + const settleAccountA = () => { + if (oldAcknowledgement === 'resolves') resolveAccountA({ code: 200 }) + else rejectAccountA(new Error('account A request failed')) + } + + try { + expect(accountBMarker).not.toBeNull() + expect(accountBFlush).not.toBe(accountAFlush) + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls.map((call) => call[2])).toEqual([ + { insert_id: accountARegistrationId, time: expect.any(Number) }, + { insert_id: accountBRegistrationId, time: expect.any(Number) }, + ]) + + settleAccountA() + await accountAFlush + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe( + accountBMarker, + ) + expect(flushRegistrationSuccess()).toBe(accountBFlush) + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + + resolveAccountB({ code: 200 }) + await accountBFlush + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + } finally { + settleAccountA() + resolveAccountB({ code: 200 }) + await Promise.allSettled([accountAFlush, accountBFlush]) + } + }, + ) + + it('coalesces concurrent flushes into one SDK send', async () => { + let resolveTrack!: (result: { code: number }) => void + mockTrackEvent.mockReturnValue({ + promise: new Promise((resolve) => { + resolveTrack = resolve + }), + }) + rememberRegistrationSuccess({ method: 'email' }) + + const first = flushRegistrationSuccess() + const second = flushRegistrationSuccess() + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + resolveTrack({ code: 200 }) + await Promise.all([first, second]) + }) + + it('discards expired and malformed markers without tracking', async () => { + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) + rememberRegistrationSuccess({ method: 'email' }) + vi.setSystemTime(new Date('2026-08-27T09:00:00.000Z')) + + await flushRegistrationSuccess() + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + + const malformedMarkers = [ + '{not-json', + JSON.stringify({ version: 1, eventName: 'user_registration_success' }), + JSON.stringify({ + version: 2, + registrationId: 'id', + occurredAt: Date.now(), + expiresAt: Date.now() + 1000, + eventName: 'arbitrary_event', + method: 'email', + attribution: {}, + }), + ] + + for (const raw of malformedMarkers) { + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, raw) + await flushRegistrationSuccess() + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + } expect(mockTrackEvent).not.toHaveBeenCalled() }) - it('should fire the event at most once across repeated flushes', () => { - rememberRegistrationSuccess({ method: 'oauth' }) + it('accepts a persisted timestamp just inside the five-minute clock-skew allowance', async () => { + vi.setSystemTime(new Date('2026-08-26T09:04:59.999Z')) + rememberRegistrationSuccess({ method: 'email' }) + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) - flushRegistrationSuccess() - flushRegistrationSuccess() + await flushRegistrationSuccess() expect(mockTrackEvent).toHaveBeenCalledTimes(1) }) - it('should discard a pending event when consent was revoked before flush', () => { - rememberRegistrationSuccess({ method: 'oauth' }) - mockConsent.value = 'denied' + it('rejects a persisted timestamp just outside the five-minute clock-skew allowance', async () => { + vi.setSystemTime(new Date('2026-08-26T09:05:00.001Z')) + rememberRegistrationSuccess({ method: 'email' }) + vi.setSystemTime(new Date('2026-08-26T09:00:00.000Z')) - flushRegistrationSuccess() + await flushRegistrationSuccess() expect(mockTrackEvent).not.toHaveBeenCalled() expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() }) - it('should clear malformed pending data without tracking', () => { - window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json') - - flushRegistrationSuccess() - - expect(mockTrackEvent).not.toHaveBeenCalled() - expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() - }) - - it('should clear the pending entry without tracking when it has no event name', () => { - window.sessionStorage.setItem( - REGISTRATION_SUCCESS_STORAGE_KEY, - JSON.stringify({ properties: { method: 'email' } }), - ) - - flushRegistrationSuccess() - - expect(mockTrackEvent).not.toHaveBeenCalled() - expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() - }) - - it('should stop without tracking when reading from sessionStorage throws', () => { + it('handles storage read errors without throwing', async () => { vi.stubGlobal('window', { sessionStorage: { getItem: () => { throw new Error('read failed') }, setItem: vi.fn(), - removeItem: vi.fn(), - }, - }) - - try { - expect(() => flushRegistrationSuccess()).not.toThrow() - expect(mockTrackEvent).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } - }) - - it('should still track when clearing the pending entry fails', () => { - const pending = { eventName: 'user_registration_success', properties: { method: 'email' } } - vi.stubGlobal('window', { - sessionStorage: { - getItem: () => JSON.stringify(pending), - setItem: vi.fn(), removeItem: () => { throw new Error('remove failed') }, }, }) - try { - flushRegistrationSuccess() - - expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { - method: 'email', - }) - } finally { - vi.unstubAllGlobals() - } - }) - }) - - // Both producers and the consumer must degrade gracefully when sessionStorage is - // missing (SSR) or blocked (privacy mode / disabled storage). - describe('when sessionStorage is unavailable', () => { - it('should no-op without throwing when window is undefined', () => { - vi.stubGlobal('window', undefined) - - try { - expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() - expect(() => flushRegistrationSuccess()).not.toThrow() - expect(mockTrackEvent).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + expect(mockTrackEvent).not.toHaveBeenCalled() }) - it('should no-op without throwing when accessing sessionStorage throws', () => { + it('retains the same marker when acknowledgement removal fails', async () => { + rememberRegistrationSuccess({ method: 'email' }) + const raw = window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + const removeItem = vi.fn(() => { + throw new Error('remove failed') + }) + vi.stubGlobal('window', { + sessionStorage: { + getItem: () => raw, + setItem: vi.fn(), + removeItem, + }, + }) + + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + + expect(mockTrackEvent).toHaveBeenCalledTimes(2) + expect(mockTrackEvent.mock.calls[0]?.[2]).toEqual(mockTrackEvent.mock.calls[1]?.[2]) + expect(removeItem).toHaveBeenCalledTimes(2) + }) + + it('no-ops when sessionStorage access is blocked', async () => { vi.stubGlobal('window', { get sessionStorage() { throw new Error('storage disabled') }, }) - try { - expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow() - expect(() => flushRegistrationSuccess()).not.toThrow() - expect(mockTrackEvent).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } + expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow() + await expect(flushRegistrationSuccess()).resolves.toBeUndefined() + expect(mockTrackEvent).not.toHaveBeenCalled() }) }) }) diff --git a/web/app/components/base/amplitude/registration-consent-coordinator.tsx b/web/app/components/base/amplitude/registration-consent-coordinator.tsx new file mode 100644 index 00000000000..8d19d05cdc2 --- /dev/null +++ b/web/app/components/base/amplitude/registration-consent-coordinator.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useEffect } from 'react' +import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' +import { coordinateRegistrationConsent } from './registration-tracking' + +export function RegistrationConsentCoordinator() { + const consent = useAnalyticsConsent() + + useEffect(() => { + coordinateRegistrationConsent(consent) + }, [consent]) + + return null +} diff --git a/web/app/components/base/amplitude/registration-session-state.ts b/web/app/components/base/amplitude/registration-session-state.ts new file mode 100644 index 00000000000..5eae9390cb1 --- /dev/null +++ b/web/app/components/base/amplitude/registration-session-state.ts @@ -0,0 +1,99 @@ +export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event' +export const OAUTH_REGISTRATION_GA_SENT_KEY = 'oauth_registration_ga_sent' +const FLUSH_RETRY_DELAYS_MS = [1000, 4000, 16000] as const + +export const REGISTRATION_METHODS = ['email', 'oauth'] as const + +export const ATTRIBUTION_KEYS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'slug', +] as const + +export type RegistrationMethod = (typeof REGISTRATION_METHODS)[number] +export type RegistrationAttribution = Partial> + +export type RegistrationIntent = { + registrationId: string + occurredAt: number + method: RegistrationMethod + attribution: RegistrationAttribution +} + +let registrationDeliveryGeneration = 0 +let flushRetryTimer: ReturnType | null = null +let flushRetryAttempt = 0 + +export const getRegistrationSessionStorage = (): Storage | null => { + try { + if (typeof window === 'undefined') return null + return window.sessionStorage + } catch { + return null + } +} + +export const removeStoredRegistrationMarker = (storage = getRegistrationSessionStorage()) => { + try { + storage?.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY) + } catch {} +} + +export const hasSentOAuthRegistrationGA = () => { + try { + return getRegistrationSessionStorage()?.getItem(OAUTH_REGISTRATION_GA_SENT_KEY) === 'true' + } catch { + return false + } +} + +export const markOAuthRegistrationGASent = () => { + try { + getRegistrationSessionStorage()?.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true') + } catch {} +} + +export const clearOAuthRegistrationGAGuard = () => { + try { + getRegistrationSessionStorage()?.removeItem(OAUTH_REGISTRATION_GA_SENT_KEY) + } catch {} +} + +export const getRegistrationDeliveryGeneration = () => registrationDeliveryGeneration + +export const clearRegistrationFlushRetry = () => { + if (flushRetryTimer !== null) { + clearTimeout(flushRetryTimer) + flushRetryTimer = null + } + flushRetryAttempt = 0 +} + +export const scheduleRegistrationFlushRetry = (runFlush: () => void) => { + if (flushRetryAttempt >= FLUSH_RETRY_DELAYS_MS.length) return + + const delay = FLUSH_RETRY_DELAYS_MS[flushRetryAttempt] + flushRetryAttempt += 1 + const generation = registrationDeliveryGeneration + if (flushRetryTimer !== null) clearTimeout(flushRetryTimer) + + flushRetryTimer = setTimeout(() => { + flushRetryTimer = null + if (generation !== registrationDeliveryGeneration) return + runFlush() + }, delay) +} + +export const invalidateRegistrationDeliveryState = () => { + registrationDeliveryGeneration += 1 + clearRegistrationFlushRetry() + removeStoredRegistrationMarker() +} + +export const discardRegistrationSessionState = () => { + invalidateRegistrationDeliveryState() + clearOAuthRegistrationGAGuard() +} diff --git a/web/app/components/base/amplitude/registration-tracking.ts b/web/app/components/base/amplitude/registration-tracking.ts index 5562d2173c4..15c11bc6590 100644 --- a/web/app/components/base/amplitude/registration-tracking.ts +++ b/web/app/components/base/amplitude/registration-tracking.ts @@ -1,38 +1,121 @@ +import type { + RegistrationAttribution, + RegistrationIntent, + RegistrationMethod, +} from './registration-session-state' +import type { AnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' +import { getIsAmplitudeInitialized } from './init' +import { + ATTRIBUTION_KEYS, + clearRegistrationFlushRetry, + getRegistrationDeliveryGeneration, + getRegistrationSessionStorage, + invalidateRegistrationDeliveryState, + REGISTRATION_METHODS, + REGISTRATION_SUCCESS_STORAGE_KEY, + removeStoredRegistrationMarker, + scheduleRegistrationFlushRetry, +} from './registration-session-state' import { trackEvent } from './utils' -/** - * Storage key for a registration success event that is waiting to be sent to - * Amplitude until a user ID has been attached. - */ -export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event' +const REGISTRATION_MARKER_VERSION = 2 +const REGISTRATION_MARKER_TTL_MS = 24 * 60 * 60 * 1000 +// Browser clocks may be corrected between registration and delivery. Permit a small +// correction, but reject timestamps far enough ahead to corrupt Amplitude ordering. +const REGISTRATION_FUTURE_CLOCK_SKEW_ALLOWANCE_MS = 5 * 60 * 1000 +const SUCCESSFUL_TRACK_RESULT_MIN = 200 +const SUCCESSFUL_TRACK_RESULT_MAX = 299 -type RegistrationMethod = 'email' | 'oauth' +const REGISTRATION_EVENT_NAMES = [ + 'user_registration_success', + 'user_registration_success_with_utm', +] as const -type PendingRegistrationSuccessEvent = { - eventName: string - properties: Record +type RegistrationEventName = (typeof REGISTRATION_EVENT_NAMES)[number] + +type PendingRegistrationSuccessEvent = RegistrationIntent & { + version: typeof REGISTRATION_MARKER_VERSION + expiresAt: number + eventName: RegistrationEventName } -const getSessionStorage = (): Storage | null => { +let registrationSnapshot = 0 +let activeFlush: { generation: number; promise: Promise } | null = null +const registrationListeners = new Set<() => void>() + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const isRegistrationMethod = (value: unknown): value is RegistrationMethod => + typeof value === 'string' && REGISTRATION_METHODS.includes(value as RegistrationMethod) + +const isRegistrationEventName = (value: unknown): value is RegistrationEventName => + typeof value === 'string' && REGISTRATION_EVENT_NAMES.includes(value as RegistrationEventName) + +const notifyRegistrationMarkerStored = () => { + registrationSnapshot += 1 + registrationListeners.forEach((listener) => listener()) +} + +const createRegistrationId = () => { try { - if (typeof window === 'undefined') return null - return window.sessionStorage + return globalThis.crypto.randomUUID() } catch { - return null + return `${Date.now()}-${Math.random().toString(36).slice(2)}` + } +} + +export const normalizeRegistrationAttribution = ( + value?: Record | null, +): RegistrationAttribution | null => { + if (!value) return null + + const attribution: RegistrationAttribution = {} + ATTRIBUTION_KEYS.forEach((key) => { + const item = value[key] + if (typeof item !== 'string') return + + const normalized = item.trim() + if (normalized) attribution[key] = normalized + }) + + return Object.keys(attribution).length ? attribution : null +} + +const createRegistrationIntent = ( + method: RegistrationMethod, + utmInfo?: Record | null, +): RegistrationIntent => ({ + registrationId: createRegistrationId(), + occurredAt: Date.now(), + method, + attribution: normalizeRegistrationAttribution(utmInfo) ?? {}, +}) + +const storeRegistrationIntent = (intent: RegistrationIntent) => { + const storage = getRegistrationSessionStorage() + if (!storage) return false + + const pending: PendingRegistrationSuccessEvent = { + ...intent, + version: REGISTRATION_MARKER_VERSION, + expiresAt: intent.occurredAt + REGISTRATION_MARKER_TTL_MS, + eventName: Object.keys(intent.attribution).length + ? 'user_registration_success_with_utm' + : 'user_registration_success', + } + + try { + storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending)) + clearRegistrationFlushRetry() + notifyRegistrationMarkerStored() + return true + } catch { + return false } } -/** - * Remember a registration success event after analytics consent so it can be sent - * to Amplitude *after* the user ID is attached (see `flushRegistrationSuccess`). - * - * Amplitude attributes events to whatever identity is active when `track` runs. At - * registration time the client does not yet know the user ID, so firing the event - * immediately records it under an anonymous profile. We persist the event here and - * replay it once `setUserId` runs in the bootstrap effects after the redirect. An - * event produced before analytics consent is granted is dropped instead of queued. - */ export const rememberRegistrationSuccess = ({ method, utmInfo, @@ -40,49 +123,157 @@ export const rememberRegistrationSuccess = ({ method: RegistrationMethod utmInfo?: Record | null }) => { - if (getAnalyticsConsent() !== 'granted') return - - const storage = getSessionStorage() - if (!storage) return - - const pending: PendingRegistrationSuccessEvent = { - eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', - properties: { method, ...utmInfo }, + const consent = getAnalyticsConsent() + if (consent === 'denied' || consent === 'disabled') { + invalidateRegistrationDeliveryState() + return false } - try { - storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending)) - } catch {} + // Persist even while consent is unknown. Flush waits for grant + Amplitude init + // + user identity, so a later full-page redirect still has the marker. + return storeRegistrationIntent(createRegistrationIntent(method, utmInfo)) } -/** - * Send a previously remembered registration success event to Amplitude. - * - * MUST be called after `setUserId` so the event lands on the identified user profile. - * No-op when nothing is pending. The pending entry is removed before tracking so the - * event fires at most once even if this runs multiple times. - */ -export const flushRegistrationSuccess = () => { - const storage = getSessionStorage() +export const coordinateRegistrationConsent = (consent: AnalyticsConsent) => { + if (consent === 'denied' || consent === 'disabled') invalidateRegistrationDeliveryState() +} + +export const subscribeRegistrationSuccess = (listener: () => void) => { + registrationListeners.add(listener) + return () => registrationListeners.delete(listener) +} + +export const getRegistrationSuccessSnapshot = () => registrationSnapshot + +const isRegistrationAttribution = (value: unknown): value is RegistrationAttribution => { + if (!isRecord(value)) return false + + return Object.entries(value).every( + ([key, item]) => + ATTRIBUTION_KEYS.includes(key as (typeof ATTRIBUTION_KEYS)[number]) && + typeof item === 'string' && + Boolean(item.trim()), + ) +} + +const parsePendingRegistration = (raw: string): PendingRegistrationSuccessEvent | null => { + try { + const value: unknown = JSON.parse(raw) + if (!isRecord(value)) return null + if (value.version !== REGISTRATION_MARKER_VERSION) return null + if (typeof value.registrationId !== 'string' || !value.registrationId) return null + if (typeof value.occurredAt !== 'number' || !Number.isFinite(value.occurredAt)) return null + if (typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt)) return null + if (value.expiresAt !== value.occurredAt + REGISTRATION_MARKER_TTL_MS) return null + if (!isRegistrationEventName(value.eventName)) return null + if (!isRegistrationMethod(value.method)) return null + if (!isRegistrationAttribution(value.attribution)) return null + + const hasAttribution = Object.keys(value.attribution).length > 0 + if (hasAttribution !== (value.eventName === 'user_registration_success_with_utm')) return null + + return value as PendingRegistrationSuccessEvent + } catch { + return null + } +} + +const runRegistrationFlush = async (generation: number) => { + const isStale = () => generation !== getRegistrationDeliveryGeneration() + if (isStale()) return + + const consent = getAnalyticsConsent() + if (consent === 'unknown') return + + const storage = getRegistrationSessionStorage() if (!storage) return - let raw: string | null = null - try { - raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) - } catch { + if (consent === 'denied' || consent === 'disabled') { + invalidateRegistrationDeliveryState() return } + if (!getIsAmplitudeInitialized()) return - if (!raw) return + while (true) { + if (isStale()) return - try { - storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY) - } catch {} + let raw: string | null + try { + raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + } catch { + return + } + if (!raw) return - if (getAnalyticsConsent() !== 'granted') return + const pending = parsePendingRegistration(raw) + const now = Date.now() + if ( + !pending || + pending.expiresAt <= now || + pending.occurredAt > now + REGISTRATION_FUTURE_CLOCK_SKEW_ALLOWANCE_MS + ) { + removeStoredRegistrationMarker(storage) + return + } - try { - const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent - if (pending?.eventName) trackEvent(pending.eventName, pending.properties) - } catch {} + let trackResult: ReturnType + try { + trackResult = trackEvent( + pending.eventName, + { + method: pending.method, + ...pending.attribution, + registration_id: pending.registrationId, + event_version: REGISTRATION_MARKER_VERSION, + tracking_contract_version: 'consent_wait_v2', + }, + { + insert_id: pending.registrationId, + time: pending.occurredAt, + }, + ) + } catch { + return + } + if (!trackResult) return + + let acknowledged = false + try { + const result: { code?: unknown } = await trackResult.promise + acknowledged = + typeof result.code === 'number' && + result.code >= SUCCESSFUL_TRACK_RESULT_MIN && + result.code <= SUCCESSFUL_TRACK_RESULT_MAX + } catch {} + if (isStale()) return + + let currentRaw: string | null + try { + currentRaw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY) + } catch { + return + } + if (currentRaw !== raw) continue + if (!acknowledged) { + scheduleRegistrationFlushRetry(() => { + void flushRegistrationSuccess() + }) + return + } + + clearRegistrationFlushRetry() + removeStoredRegistrationMarker(storage) + return + } +} + +export function flushRegistrationSuccess() { + const generation = getRegistrationDeliveryGeneration() + if (activeFlush?.generation === generation) return activeFlush.promise + + const promise = runRegistrationFlush(generation).finally(() => { + if (activeFlush?.promise === promise) activeFlush = null + }) + activeFlush = { generation, promise } + return promise } diff --git a/web/app/components/base/amplitude/utils.ts b/web/app/components/base/amplitude/utils.ts index 58354463fc1..bb6021d0ad1 100644 --- a/web/app/components/base/amplitude/utils.ts +++ b/web/app/components/base/amplitude/utils.ts @@ -9,8 +9,13 @@ const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplit * @param eventName Event name * @param eventProperties Event properties (optional) */ -export const trackEvent = (eventName: string, eventProperties?: Record) => { +export const trackEvent = ( + eventName: string, + eventProperties?: Record, + eventOptions?: amplitude.Types.EventOptions, +) => { if (!canUseAmplitude()) return + if (eventOptions) return amplitude.track(eventName, eventProperties, eventOptions) return amplitude.track(eventName, eventProperties) } diff --git a/web/app/components/base/analytics-consent/__tests__/analytics-disabled.spec.tsx b/web/app/components/base/analytics-consent/__tests__/analytics-disabled.spec.tsx new file mode 100644 index 00000000000..65443ba0046 --- /dev/null +++ b/web/app/components/base/analytics-consent/__tests__/analytics-disabled.spec.tsx @@ -0,0 +1,26 @@ +import { render, waitFor } from '@testing-library/react' +import { REGISTRATION_SUCCESS_STORAGE_KEY } from '../../amplitude/registration-session-state' +import { + coordinateRegistrationConsent, + rememberRegistrationSuccess, +} from '../../amplitude/registration-tracking' +import { AnalyticsDisabled } from '../analytics-disabled' +import { getAnalyticsConsent, setAnalyticsConsent } from '../consent-store' + +describe('AnalyticsDisabled', () => { + beforeEach(() => { + window.sessionStorage.clear() + coordinateRegistrationConsent('denied') + setAnalyticsConsent('granted') + }) + + it('terminally discards a pending registration marker', async () => { + rememberRegistrationSuccess({ method: 'email' }) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).not.toBeNull() + + render() + + await waitFor(() => expect(getAnalyticsConsent()).toBe('disabled')) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx b/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx index bfad2dc720a..d7dabf25264 100644 --- a/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx +++ b/web/app/components/base/analytics-consent/__tests__/analytics-runtimes.spec.tsx @@ -14,6 +14,10 @@ vi.mock('@/app/components/base/amplitude/WebAppAmplitudeProvider', () => ({ WebAppAmplitudeProvider: () => , })) +vi.mock('@/app/components/base/amplitude/registration-consent-coordinator', () => ({ + RegistrationConsentCoordinator: () => , +})) + vi.mock('@/app/components/external-attribution-recorder', () => ({ default: () => , })) @@ -24,6 +28,7 @@ describe('analytics runtimes', () => { expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument() expect(screen.getByTestId('console-amplitude-provider')).toBeInTheDocument() + expect(screen.getByTestId('registration-consent-coordinator')).toBeInTheDocument() expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument() expect(screen.queryByTestId('web-app-amplitude-provider')).toBeNull() }) @@ -34,6 +39,7 @@ describe('analytics runtimes', () => { expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument() expect(screen.getByTestId('web-app-amplitude-provider')).toBeInTheDocument() expect(screen.queryByTestId('console-amplitude-provider')).toBeNull() + expect(screen.queryByTestId('registration-consent-coordinator')).toBeNull() expect(screen.queryByTestId('external-attribution-recorder')).toBeNull() }) }) diff --git a/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx b/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx index 2380b7485c0..a4dc0dc299b 100644 --- a/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx +++ b/web/app/components/base/analytics-consent/__tests__/cloud-analytics.spec.tsx @@ -1,5 +1,6 @@ import { QueryClient } from '@tanstack/react-query' import { render } from '@testing-library/react' +import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state' let queryClient: QueryClient @@ -55,9 +56,13 @@ vi.mock('../cloud-analytics-layout-boundary', () => ({ ), })) -async function renderCloudAnalytics() { +async function getCloudAnalyticsResult() { const { CloudAnalytics } = await import('../cloud-analytics') - return render(await CloudAnalytics()) + return CloudAnalytics() +} + +async function renderCloudAnalytics() { + return render(await getCloudAnalyticsResult()) } describe('CloudAnalytics', () => { @@ -68,6 +73,7 @@ describe('CloudAnalytics', () => { configState.isProd = true configState.webPrefix = 'https://cloud.dify.ai' queryClient = new QueryClient() + window.sessionStorage.clear() queryClient.setQueryData(systemFeaturesQueryKey, { deployment_edition: 'CLOUD' }) mockHeadersGet.mockImplementation((name: string) => { const values: Record = { @@ -94,9 +100,13 @@ describe('CloudAnalytics', () => { return values[name] ?? null }) - const { queryByTestId } = await renderCloudAnalytics() + const result = await getCloudAnalyticsResult() + const { queryByTestId } = render(result) expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull() + expect(result).not.toBeNull() + const { getAnalyticsConsent } = await import('../consent-store') + expect(getAnalyticsConsent()).toBe('disabled') }) it.each(['COMMUNITY', 'ENTERPRISE'] as const)( @@ -109,11 +119,16 @@ describe('CloudAnalytics', () => { }, ) - it('does not render when System Features are unavailable', async () => { + it('suspends analytics without deleting pending registration state when System Features are unavailable', async () => { queryClient.removeQueries({ queryKey: systemFeaturesQueryKey }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'pending-marker') - const { queryByTestId } = await renderCloudAnalytics() + const result = await getCloudAnalyticsResult() + const { queryByTestId } = render(result) expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull() + const { getAnalyticsConsent } = await import('../consent-store') + expect(getAnalyticsConsent()).toBe('unknown') + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('pending-marker') }) }) diff --git a/web/app/components/base/analytics-consent/analytics-disabled.tsx b/web/app/components/base/analytics-consent/analytics-disabled.tsx new file mode 100644 index 00000000000..a562e67c51a --- /dev/null +++ b/web/app/components/base/analytics-consent/analytics-disabled.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useEffect } from 'react' +import { coordinateRegistrationConsent } from '@/app/components/base/amplitude/registration-tracking' +import { setAnalyticsConsent } from './consent-store' + +export function AnalyticsDisabled() { + useEffect(() => { + setAnalyticsConsent('disabled') + coordinateRegistrationConsent('disabled') + }, []) + + return null +} diff --git a/web/app/components/base/analytics-consent/cloud-analytics.tsx b/web/app/components/base/analytics-consent/cloud-analytics.tsx index 9b53224b494..cf4bd0055f7 100644 --- a/web/app/components/base/analytics-consent/cloud-analytics.tsx +++ b/web/app/components/base/analytics-consent/cloud-analytics.tsx @@ -1,6 +1,7 @@ import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config' import { getCachedSystemFeatures } from '@/features/system-features/server' import { headers } from '@/next/headers' +import { AnalyticsDisabled } from './analytics-disabled' import { CloudAnalyticsLayoutBoundary } from './cloud-analytics-layout-boundary' import { isCloudAnalyticsRequest } from './request-boundary' @@ -19,7 +20,7 @@ export async function CloudAnalytics() { webPrefix: WEB_PREFIX, }) - if (!enabled) return null + if (!enabled) return const nonce = requestHeaders.get('x-nonce') ?? undefined diff --git a/web/app/components/base/analytics-consent/consent-store.ts b/web/app/components/base/analytics-consent/consent-store.ts index 030d3f770b7..b1f77c2eac7 100644 --- a/web/app/components/base/analytics-consent/consent-store.ts +++ b/web/app/components/base/analytics-consent/consent-store.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react' -export type AnalyticsConsent = 'unknown' | 'denied' | 'granted' +export type AnalyticsConsent = 'unknown' | 'denied' | 'granted' | 'disabled' type CookieYesConsentUpdateDetail = { accepted: string[] diff --git a/web/app/components/base/analytics-consent/console-analytics-runtime.tsx b/web/app/components/base/analytics-consent/console-analytics-runtime.tsx index b0f97e5cd7e..00814fad51c 100644 --- a/web/app/components/base/analytics-consent/console-analytics-runtime.tsx +++ b/web/app/components/base/analytics-consent/console-analytics-runtime.tsx @@ -1,6 +1,7 @@ 'use client' import AmplitudeProvider from '@/app/components/base/amplitude' +import { RegistrationConsentCoordinator } from '@/app/components/base/amplitude/registration-consent-coordinator' import ExternalAttributionRecorder from '@/app/components/external-attribution-recorder' import { CookieYesConsentBridge } from './cookieyes-consent-bridge' @@ -9,6 +10,7 @@ export function ConsoleAnalyticsRuntime() { <> + ) diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index f8cb056ecda..67ff538474d 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -179,6 +179,7 @@ function AppIconPickerContent({ return ( & { - ref?: React.RefObject> -}) => - -Icon.displayName = 'Trigger' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/plugin/index.ts b/web/app/components/base/icons/src/vender/plugin/index.ts index b345526eb77..943c7641161 100644 --- a/web/app/components/base/icons/src/vender/plugin/index.ts +++ b/web/app/components/base/icons/src/vender/plugin/index.ts @@ -1,3 +1,2 @@ export { default as BoxSparkleFill } from './BoxSparkleFill' export { default as LeftCorner } from './LeftCorner' -export { default as Trigger } from './Trigger' diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx index 7c4b00da20e..764b51cb52c 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx @@ -19,6 +19,7 @@ const expectedAppACLPermissionKeys = [ 'app.acl.tracing_config', 'app.acl.log_and_annotation', 'app.acl.access_config', + 'app.acl.access_point_manage', ] const getPermissionKeyMatcher = (permissionKey: string) => diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts index 8e0dc0675d8..cae824f953e 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts @@ -25,7 +25,7 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ describe('useMarketplaceAllPlugins', () => { const mockQueryPlugins = vi.fn() const mockQueryPluginsWithDebounced = vi.fn() - const mockResetPlugins = vi.fn() + const mockResetQueryParams = vi.fn() const mockCancelQueryPluginsWithDebounced = vi.fn() const mockFetchNextPage = vi.fn() @@ -35,7 +35,7 @@ describe('useMarketplaceAllPlugins', () => { ({ plugins: [], total: 0, - resetPlugins: mockResetPlugins, + resetQueryParams: mockResetQueryParams, queryPlugins: mockQueryPlugins, queryPluginsWithDebounced: mockQueryPluginsWithDebounced, cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced, diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 72b453c3293..34e10b7fb02 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -268,12 +268,14 @@ export const useMarketplaceAllPlugins = ( queryPlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced = () => {}, + resetQueryParams = () => {}, isLoading: isPluginsLoading, } = useMarketplacePlugins(enabled) useEffect(() => { if (!enabled) { cancelQueryPluginsWithDebounced() + resetQueryParams() return } @@ -302,6 +304,7 @@ export const useMarketplaceAllPlugins = ( enabled, queryPlugins, queryPluginsWithDebounced, + resetQueryParams, searchText, exclude, ]) diff --git a/web/app/components/integrations/tool-provider-card.tsx b/web/app/components/integrations/tool-provider-card.tsx index f6aca486a20..27dc904c968 100644 --- a/web/app/components/integrations/tool-provider-card.tsx +++ b/web/app/components/integrations/tool-provider-card.tsx @@ -131,13 +131,13 @@ function IntegrationsToolProviderCard({
{!!org && ( <> -
+
{org}
/
)} -
+
{name}
diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 9408839897d..10fce19ce42 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -723,19 +723,6 @@ describe('MainNav', () => { expect( marketplaceLink.querySelector('.i-custom-vender-main-nav-marketplace-v2'), ).toBeInTheDocument() - expect( - within(screen.getByRole('navigation')) - .getAllByRole('link') - .map((link) => link.getAttribute('href')), - ).toEqual([ - '/', - '/apps', - '/agents', - '/datasets', - '/skills', - '/integrations/model-provider', - '/marketplace', - ]) }) it('hides the roster entry when Agent v2 is disabled', () => { @@ -979,14 +966,17 @@ describe('MainNav', () => { ) }) - it('marks marketplace active on marketplace routes', () => { - mockPathname = '/marketplace' + it.each(['/marketplace', '/plugins', '/templates', '/templates/marketing'])( + 'marks marketplace active on route %s', + (pathname) => { + mockPathname = pathname - renderMainNav() + renderMainNav() - const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) - expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) - }) + const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) + expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) + }, + ) it('marks roster active on roster routes', () => { mockPathname = '/agents' @@ -1184,7 +1174,8 @@ describe('MainNav', () => { 'common.mainNav.help.learnDify', 'common.mainNav.help.stepByStepTour', 'common.userProfile.compliance', - 'Discord', + 'common.userProfile.discord', + 'common.mainNav.help.creatorCenter', 'common.userProfile.github', 'common.userProfile.about', ] @@ -1195,6 +1186,23 @@ describe('MainNav', () => { }) }) + it('opens Creator Center from the help menu above GitHub', async () => { + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + const creatorCenter = await screen.findByRole('menuitem', { + name: 'common.mainNav.help.creatorCenter', + }) + const github = screen.getByRole('menuitem', { name: /common\.userProfile\.github/ }) + + expect(creatorCenter).toHaveAttribute('href', 'https://creators.dify.ai/') + expect(creatorCenter).toHaveAttribute('target', '_blank') + expect(creatorCenter).toHaveAttribute('rel', 'noopener noreferrer') + expect(creatorCenter.compareDocumentPosition(github)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(creatorCenter.querySelector('.i-ri-user-star-line')).toBeTruthy() + }) + it('opens About from its real Help menu owner and restores focus when closed', async () => { const user = userEvent.setup() mockConsoleState.current = { @@ -1267,7 +1275,7 @@ describe('MainNav', () => { fireEvent.click(contactUsItem) await waitFor(() => { - expect(screen.queryByText('Discord')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.discord')).not.toBeInTheDocument() }) expect(mockSetShowPricingModal).toHaveBeenCalled() }) diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index 1645b32a690..c2833868fea 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import type { Mock } from 'vite-plus/test' +import { useSuspenseQuery } from '@tanstack/react-query' import { fireEvent, screen } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -10,6 +11,7 @@ import MainNavLayout from '../layout' const mockConsoleState = vi.hoisted(() => ({ current: { isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, }, })) @@ -22,6 +24,14 @@ vi.mock('@/app/components/header/header-wrapper', () => ({
{children}
), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: vi.fn(), + } +}) + vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => mockConsoleState.current) @@ -55,7 +65,13 @@ describe('MainNavLayout', () => { ;(usePathname as Mock).mockReturnValue('/apps') mockConsoleState.current = { isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, } + ;(useSuspenseQuery as Mock).mockReturnValue({ + data: { + enable_app_deploy: true, + }, + }) ;(isAgentV2Enabled as Mock).mockReturnValue(true) }) @@ -205,29 +221,64 @@ describe('MainNavLayout', () => { expect(screen.getByTestId('main-nav')).toBeInTheDocument() }) - it.each(['/datasets/create', '/datasets/new/create', '/datasets/dataset-1/documents/create'])( - 'keeps the global main nav on collection and creation route %s', - (pathname) => { - ;(usePathname as Mock).mockReturnValue(pathname) + it.each([ + '/datasets/create', + '/datasets/new/create', + '/datasets/dataset-1/documents/create', + '/deployments/create', + ])('keeps the global main nav on collection and creation route %s', (pathname) => { + ;(usePathname as Mock).mockReturnValue(pathname) - render( - Detail sidebar}> -
content
-
, - ) + render( + Detail sidebar}> +
content
+
, + ) - expect(screen.getByTestId('main-nav')).toBeInTheDocument() - expect( - screen.queryByRole('complementary', { name: 'Detail sidebar' }), - ).not.toBeInTheDocument() + expect(screen.getByTestId('main-nav')).toBeInTheDocument() + expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument() + }) + + it.each([ + { + label: 'agent detail route for dataset operators', + pathname: '/agents/agent-1/configure', + consoleState: { + isCurrentWorkspaceDatasetOperator: true, + isCurrentWorkspaceEditor: true, + }, + systemFeatures: { + enable_app_deploy: true, + }, }, - ) - - it('keeps the global main nav on agent detail routes for dataset operators', () => { - ;(usePathname as Mock).mockReturnValue('/agents/agent-1/configure') - mockConsoleState.current = { - isCurrentWorkspaceDatasetOperator: true, - } + { + label: 'deployment detail route for non-editor workspaces', + pathname: '/deployments/app-instance-1/overview', + consoleState: { + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: false, + }, + systemFeatures: { + enable_app_deploy: true, + }, + }, + { + label: 'deployment detail route when deployment is disabled', + pathname: '/deployments/app-instance-1/overview', + consoleState: { + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, + }, + systemFeatures: { + enable_app_deploy: false, + }, + }, + ])('keeps the global main nav on $label', ({ pathname, consoleState, systemFeatures }) => { + ;(usePathname as Mock).mockReturnValue(pathname) + mockConsoleState.current = consoleState + ;(useSuspenseQuery as Mock).mockReturnValue({ + data: systemFeatures, + }) render( Detail sidebar}> diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index 17f01fe1c3e..d3bcd3454c1 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -104,12 +104,18 @@ describe('SupportMenu', () => { renderSupportMenu() expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() + expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument() expect( screen .getByText('common.userProfile.contactUs') - .compareDocumentPosition(screen.getByText('Discord')), + .compareDocumentPosition(screen.getByText('common.userProfile.discord')), ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getByRole('menuitem', { name: 'common.userProfile.discord' })).toHaveClass( + 'mx-0', + 'px-3', + ) fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' })) @@ -155,7 +161,7 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() }) it('keeps Zendesk contact us for Cloud sandbox plan with support email and Zendesk configured', () => { @@ -207,7 +213,7 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() }) it('renders email support when Zendesk is not configured for a dedicated support channel', () => { @@ -223,12 +229,12 @@ describe('SupportMenu', () => { ).toHaveAttribute('href', 'mailto:support@example.com') }) - it('has the correct Discord link', () => { + it('has the Discord link and no Forum entry', () => { renderSupportMenu() - expect(screen.getByRole('menuitem', { name: 'Discord' })).toHaveAttribute( - 'href', - 'https://discord.gg/5AEfbxcd9k', - ) + const discordLink = screen.getByText('common.userProfile.discord').closest('a') + expect(discordLink).toHaveAttribute('href', 'https://discord.gg/5AEfbxcd9k') + expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index e6851679659..f7d26d8a3a1 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -29,6 +29,7 @@ import { MenuItemContent, } from '@/app/components/header/account-dropdown/menu-item-content' import GithubStar from '@/app/components/header/github-star' +import { useCreatorCenterUrl } from '@/app/components/plugins/marketplace/creator-center-url' import { trackStepByStepTourEvent } from '@/app/components/step-by-step-tour/analytics' import { disableStepByStepTourForCurrentWorkspaceAtom, @@ -38,6 +39,7 @@ import { stepByStepTourStateUpdatingAtom, } from '@/app/components/step-by-step-tour/state' import { useSetStepByStepTourShellMode } from '@/app/components/step-by-step-tour/storage' +import { MARKETPLACE_URL_PREFIX } from '@/config' import { getLangGeniusVersionInfo } from '@/context/app-context-normalizers' import { useDocLink } from '@/context/i18n' import { @@ -91,6 +93,7 @@ const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => ( const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: HelpMenuProps) => { const { t } = useTranslation() const docLink = useDocLink() + const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: profileMeta } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -252,6 +255,18 @@ const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: He + + $['mainNav.help.creatorCenter'], { ns: 'common' })} + trailing={} + /> + ( - + + + ) type MainNavLinkProps = { @@ -30,7 +32,10 @@ const MainNavLink = ({ item, pathname, children }: MainNavLinkProps) => { )} > - + {item.label} diff --git a/web/app/components/main-nav/components/support-menu.tsx b/web/app/components/main-nav/components/support-menu.tsx index 4bb93f0999c..8b49450edc0 100644 --- a/web/app/components/main-nav/components/support-menu.tsx +++ b/web/app/components/main-nav/components/support-menu.tsx @@ -104,7 +104,7 @@ export default function SupportMenu() { > $['userProfile.discord'], { ns: 'common' })} trailing={} /> diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index 78c5958ca4b..d8ec10883b4 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -43,7 +43,7 @@ export const MAIN_NAV_ROUTES = [ key: 'home', href: '/', labelKey: 'mainNav.home', - active: (path: string) => path === '/', + active: (path: string) => path === '/' || path === '/explore/apps', icon: 'i-custom-vender-main-nav-home-v2', activeIcon: 'i-custom-vender-main-nav-home-v2-active', visibility: VISIBLE_TO_ALL, @@ -70,15 +70,6 @@ export const MAIN_NAV_ROUTES = [ visibility: CAN_MANAGE_AGENTS, feature: 'agentV2', }, - { - key: 'datasets', - href: '/datasets', - labelKey: 'menus.datasets', - active: (path: string) => isPathUnderRoute(path, '/datasets'), - icon: 'i-custom-vender-main-nav-knowledge-v2', - activeIcon: 'i-custom-vender-main-nav-knowledge-v2-active', - visibility: VISIBLE_TO_ALL, - }, { key: 'skills', href: '/skills', @@ -88,6 +79,15 @@ export const MAIN_NAV_ROUTES = [ activeIcon: 'i-custom-vender-main-nav-skill-active', visibility: SKILL_ENABLED_FOR_WORKSPACE, }, + { + key: 'datasets', + href: '/datasets', + labelKey: 'menus.datasets', + active: (path: string) => isPathUnderRoute(path, '/datasets'), + icon: 'i-custom-vender-main-nav-knowledge-v2', + activeIcon: 'i-custom-vender-main-nav-knowledge-v2-active', + visibility: VISIBLE_TO_ALL, + }, { key: 'integrations', href: buildIntegrationPath('provider'), @@ -103,7 +103,9 @@ export const MAIN_NAV_ROUTES = [ href: '/marketplace', labelKey: 'mainNav.marketplace', active: (path: string) => - isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), + isPathUnderRoute(path, '/marketplace') || + isPathUnderRoute(path, '/plugins') || + isPathUnderRoute(path, '/templates'), icon: 'i-custom-vender-main-nav-marketplace-v2', activeIcon: 'i-custom-vender-main-nav-marketplace-v2-active', visibility: VISIBLE_TO_ALL, diff --git a/web/app/components/oauth-registration-analytics.tsx b/web/app/components/oauth-registration-analytics.tsx index fd7eb3bc542..e82adaf2923 100644 --- a/web/app/components/oauth-registration-analytics.tsx +++ b/web/app/components/oauth-registration-analytics.tsx @@ -2,9 +2,18 @@ import Cookies from 'js-cookie' import { useEffect, useRef } from 'react' +import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store' import { useSearchParams } from '@/next/navigation' import { sendGAEvent } from '@/utils/gtag' -import { rememberRegistrationSuccess } from './base/amplitude/registration-tracking' +import { + clearOAuthRegistrationGAGuard, + hasSentOAuthRegistrationGA, + markOAuthRegistrationGASent, +} from './base/amplitude/registration-session-state' +import { + normalizeRegistrationAttribution, + rememberRegistrationSuccess, +} from './base/amplitude/registration-tracking' const OAUTH_NEW_USER_PARAM = 'oauth_new_user' @@ -18,46 +27,75 @@ const removeOAuthNewUserParam = () => { } export function OAuthRegistrationAnalytics() { + const analyticsConsent = useAnalyticsConsent() const searchParams = useSearchParams() const oauthNewUserParam = searchParams.get(OAUTH_NEW_USER_PARAM) - const handledParamRef = useRef(null) + const gaHandledRef = useRef(false) + const amplitudeHandledRef = useRef(false) + const cleanedRef = useRef(false) + const utmInfoRef = useRef | undefined>( + undefined, + ) useEffect(() => { - if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam) return - - handledParamRef.current = oauthNewUserParam - const oauthNewUser = oauthNewUserParam === 'true' - if (!oauthNewUser) { - removeOAuthNewUserParam() + if (oauthNewUserParam === null) { + clearOAuthRegistrationGAGuard() return } - let utmInfo: Record | null = null - const utmInfoStr = Cookies.get('utm_info') - if (utmInfoStr) { - try { - const parsed: unknown = JSON.parse(utmInfoStr) - if (isRecord(parsed)) utmInfo = parsed - } catch (e) { - console.error('Failed to parse utm_info cookie:', e) + const oauthNewUser = oauthNewUserParam === 'true' + if (!oauthNewUser) { + if (!cleanedRef.current) { + cleanedRef.current = true + clearOAuthRegistrationGAGuard() + removeOAuthNewUserParam() } + return } + if (utmInfoRef.current === undefined) { + let parsedUtmInfo: Record | null = null + const utmInfoStr = Cookies.get('utm_info') + if (utmInfoStr) { + try { + const parsed: unknown = JSON.parse(utmInfoStr) + if (isRecord(parsed)) parsedUtmInfo = parsed + } catch (e) { + console.error('Failed to parse utm_info cookie:', e) + } + } + utmInfoRef.current = normalizeRegistrationAttribution(parsedUtmInfo) + } + const utmInfo = utmInfoRef.current + const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success' - // Defer the Amplitude event until the user ID is attached. The app context - // external sync replays it after setUserId runs. Firing it here would record it under an - // anonymous Amplitude profile (no user ID set yet). - rememberRegistrationSuccess({ method: 'oauth', utmInfo }) + if (!gaHandledRef.current) { + gaHandledRef.current = true + if (!hasSentOAuthRegistrationGA()) { + sendGAEvent(eventName, { + method: 'oauth', + ...utmInfo, + }) + markOAuthRegistrationGASent() + } + } - sendGAEvent(eventName, { - method: 'oauth', - ...utmInfo, - }) + if ( + (analyticsConsent === 'unknown' || analyticsConsent === 'granted') && + !amplitudeHandledRef.current + ) { + const persisted = rememberRegistrationSuccess({ method: 'oauth', utmInfo }) + if (!persisted) return + amplitudeHandledRef.current = true + } - Cookies.remove('utm_info') - removeOAuthNewUserParam() - }, [oauthNewUserParam]) + if (!cleanedRef.current) { + cleanedRef.current = true + Cookies.remove('utm_info') + removeOAuthNewUserParam() + } + }, [analyticsConsent, oauthNewUserParam]) return null } diff --git a/web/app/components/plugins/base/badges/partner.tsx b/web/app/components/plugins/base/badges/partner.tsx index 6d97d3b4898..41663ffee80 100644 --- a/web/app/components/plugins/base/badges/partner.tsx +++ b/web/app/components/plugins/base/badges/partner.tsx @@ -1,3 +1,5 @@ +'use client' + import type { FC } from 'react' import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark' import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight' diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx new file mode 100644 index 00000000000..e2d32e68f29 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -0,0 +1,86 @@ +import type { CardPayload } from '../index' +import { render } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { PluginCategoryEnum } from '../../types' +import Card from '../index' + +vi.mock('jotai', () => ({ + useAtomValue: vi.fn(), +})) + +vi.mock('@/context/workspace-state', () => ({ + currentWorkspaceIdAtom: Symbol('currentWorkspaceIdAtom'), +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string | ((...args: never[]) => unknown)) => + typeof key === 'string' ? key : 'translated', + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record) => value['en-US'] ?? '', +})) + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categoriesMap: { + tool: { label: 'Tool' }, + }, + }), +})) + +const marketplacePlugin = { + badges: [], + brief: { 'en-US': 'Marketplace plugin description' }, + category: PluginCategoryEnum.tool, + description: { 'en-US': 'Marketplace plugin description' }, + endpoint: { settings: [] }, + from: 'marketplace', + icon: 'icon.png', + install_count: 0, + introduction: '', + label: { 'en-US': 'Marketplace plugin' }, + latest_package_identifier: 'langgenius/demo-plugin:1.0.0', + latest_version: '1.0.0', + name: 'demo-plugin', + org: 'langgenius', + plugin_id: 'langgenius/demo-plugin', + repository: '', + tags: [], + type: 'plugin', + verified: false, + verification: { authorized_category: 'langgenius' }, + version: '1.0.0', +} satisfies CardPayload + +describe('Plugin card workspace boundary', () => { + it('renders Marketplace variant icons without reading Dify workspace state', () => { + vi.mocked(useAtomValue).mockImplementation(() => { + throw new Error('Dify workspace state must not be read') + }) + + const payloadWithoutSource = { + ...marketplacePlugin, + from: undefined, + } as unknown as CardPayload + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute( + 'src', + `${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`, + ) + expect(useAtomValue).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/card/base/org-info.tsx b/web/app/components/plugins/card/base/org-info.tsx index 2d9ef6ce036..9989cd454cb 100644 --- a/web/app/components/plugins/card/base/org-info.tsx +++ b/web/app/components/plugins/card/base/org-info.tsx @@ -13,7 +13,7 @@ const OrgInfo = ({ className, orgName, packageName, packageNameClassName }: Prop {orgName && ( <> {orgName} @@ -23,7 +23,7 @@ const OrgInfo = ({ className, orgName, packageName, packageNameClassName }: Prop )} +type CardIconProps = { + icon: CardPayload['icon'] + installFailed?: boolean + installed?: boolean + marketplace?: boolean + plugin: Pick +} + +const WorkspaceCardIcon = ({ icon, installFailed, installed, plugin }: CardIconProps) => { + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) + const iconSrc = getPluginCardIconUrl(plugin, icon, currentWorkspaceId) + + return +} + +const CardIcon = ({ icon, installFailed, installed, marketplace, plugin }: CardIconProps) => { + if (marketplace || plugin.from === 'marketplace') { + const iconSrc = getPluginCardIconUrl({ ...plugin, from: 'marketplace' }, icon, '') + return + } + + return ( + + ) +} + const Card = ({ className, payload, @@ -60,15 +91,11 @@ const Card = ({ const locale = useGetLanguage() const { t } = useTranslation() const { categoriesMap } = useCategories(true) - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload const badges = payload.badges ?? [] const { theme } = useTheme() - const iconSrc = getPluginCardIconUrl( - { from, name, org, type }, - theme === Theme.dark && icon_dark ? icon_dark : icon, - currentWorkspaceId, - ) + const activeIcon = theme === Theme.dark && icon_dark ? icon_dark : icon + const pluginIdentity = { from, name, org, type } const getLocalizedText = (obj: Record | undefined) => obj ? renderI18nObject(obj, locale) : '' const isPartner = badges.includes('partner') @@ -92,7 +119,13 @@ const Card = ({
{!hideCornerMark && }
- +
@@ -152,7 +185,12 @@ const Card = ({ {!hideCornerMark && } {/* Header */}
- +
diff --git a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx index cc440d567f4..87fb5039af0 100644 --- a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx @@ -6,6 +6,7 @@ import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { useActivePluginType, useFilterPluginTags, + useFilterTemplateLanguages, useMarketplaceMoreClick, useMarketplaceSearchMode, useMarketplaceSort, @@ -128,6 +129,25 @@ describe('useFilterPluginTags', () => { }) }) +describe('useFilterTemplateLanguages', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return empty array as default', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper }) + + expect(result.current[0]).toEqual([]) + }) + + it('parses languages from search params', () => { + const { wrapper } = createWrapper('?languages=ja') + const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper }) + expect(result.current[0]).toEqual(['ja']) + }) +}) + describe('useMarketplaceSearchMode', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts b/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts new file mode 100644 index 00000000000..a8a82ebe809 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + getCreatorCenterUrl, + PUBLIC_CREATOR_CENTER_URL, + rewriteMarketplaceOriginToCreators, +} from '../creator-center-url' + +describe('getCreatorCenterUrl', () => { + it('maps the public Marketplace to the public Creator Center', () => { + expect(getCreatorCenterUrl('https://marketplace.dify.ai')).toBe('https://creators.dify.ai/') + }) + + it('maps marketplace.dify.dev to creators.dify.dev', () => { + expect(getCreatorCenterUrl('https://marketplace.dify.dev')).toBe('https://creators.dify.dev/') + }) + + it('keeps the staging suffix on the Creators host', () => { + expect(getCreatorCenterUrl('https://marketplace-staging.dify.dev')).toBe( + 'https://creators-staging.dify.dev/', + ) + }) + + it('falls back to the public Creator Center for localhost', () => { + expect(getCreatorCenterUrl('http://localhost:3000')).toBe(PUBLIC_CREATOR_CENTER_URL) + }) + + it('falls back to the public Creator Center when the prefix is empty', () => { + expect(getCreatorCenterUrl('')).toBe(PUBLIC_CREATOR_CENTER_URL) + }) + + it('prefers the current Marketplace page over a stale configured prefix', () => { + expect(getCreatorCenterUrl('https://marketplace.dify.ai', 'https://marketplace.dify.dev')).toBe( + 'https://creators.dify.dev/', + ) + }) +}) + +describe('rewriteMarketplaceOriginToCreators', () => { + it('returns null for hosts that are not a Marketplace surface', () => { + expect(rewriteMarketplaceOriginToCreators('https://cloud.dify.ai')).toBeNull() + expect(rewriteMarketplaceOriginToCreators('http://localhost:3000')).toBeNull() + expect(rewriteMarketplaceOriginToCreators('')).toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx new file mode 100644 index 00000000000..5f20986e463 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx @@ -0,0 +1,154 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ReactNode } 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 mockFetchPluginBanners = vi.fn() + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'zh-Hans', +})) + +vi.mock('../home/banners', async (importOriginal) => { + const original = await importOriginal<typeof import('../home/banners')>() + + return { + ...original, + fetchPluginBanners: (...args: unknown[]) => mockFetchPluginBanners(...args), + } +}) + +vi.mock('../view', () => ({ + MarketplaceView: ({ + banners, + showInstallButton, + }: { + banners: PluginBanner[] + showInstallButton: boolean + }) => ( + <div> + <p>Trending banners: {banners.length}</p> + <p>{showInstallButton ? 'Install enabled' : 'Install disabled'}</p> + </div> + ), +})) + +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +describe('EmbeddedMarketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + }) + + it('loads homepage banners on the client for the active locale', async () => { + mockFetchPluginBanners.mockResolvedValue([ + { + id: 'banner-1', + title: 'Trending', + sort: 1, + language: 'zh-Hans', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[]) + + const { EmbeddedMarketplace } = await import('../embedded') + + render(<EmbeddedMarketplace showInstallButton variant="home" />, { wrapper: Wrapper }) + + expect(await screen.findByText('Trending banners: 1')).toBeInTheDocument() + expect(screen.getByText('Install enabled')).toBeInTheDocument() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans') + }) + + it('uses server-rendered homepage banners without requesting them again on hydration', async () => { + const initialBanners = [ + { + id: 'banner-1', + title: 'Trending', + sort: 1, + language: 'zh-Hans', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[] + + const { EmbeddedMarketplace } = await import('../embedded') + + render( + <EmbeddedMarketplace + initialBanners={initialBanners} + initialLocale="zh-Hans" + showInstallButton + variant="home" + />, + { wrapper: Wrapper }, + ) + + expect(screen.getByText('Trending banners: 1')).toBeInTheDocument() + expect(mockFetchPluginBanners).not.toHaveBeenCalled() + }) + + it('refetches banners when the client locale differs from the server-rendered locale', async () => { + const initialBanners = [ + { + id: 'banner-en', + title: 'Trending', + sort: 1, + language: 'en-US', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[] + mockFetchPluginBanners.mockResolvedValue([]) + + const { EmbeddedMarketplace } = await import('../embedded') + + render( + <EmbeddedMarketplace + initialBanners={initialBanners} + initialLocale="en-US" + showInstallButton + variant="home" + />, + { wrapper: Wrapper }, + ) + + expect(await screen.findByText('Trending banners: 0')).toBeInTheDocument() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans') + }) + + it('does not request homepage banners for the default catalog variant', async () => { + const { EmbeddedMarketplace } = await import('../embedded') + + render(<EmbeddedMarketplace variant="default" />, { wrapper: Wrapper }) + + expect(screen.getByText('Trending banners: 0')).toBeInTheDocument() + expect(mockFetchPluginBanners).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx index 46c770694b2..567f32f5b58 100644 --- a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react' +import type { Plugin } from '@/app/components/plugins/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' +import { PluginCategoryEnum } from '@/app/components/plugins/types' const getMarketplacePluginsByCollectionId = vi.hoisted(() => vi.fn()) const getMarketplaceCollectionsAndPlugins = vi.hoisted(() => vi.fn()) @@ -149,3 +151,79 @@ describe('useMarketplaceCollectionsAndPlugins', () => { }) }) }) + +const createPlugin = (pluginID: string, category: PluginCategoryEnum) => + ({ + plugin_id: pluginID, + type: 'plugin', + category, + }) as Plugin + +const createInfiniteData = (plugin: Plugin, pageSize: number) => ({ + pages: [ + { + plugins: [plugin], + total: 1, + page: 1, + page_size: pageSize, + }, + ], + pageParams: [1], +}) + +const createWrapperWithQueryClient = (queryClient: QueryClient) => + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + } + +describe('useMarketplacePlugins', () => { + it('should reset local query params without removing marketplace plugin caches', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + }, + }) + const toolPlugin = createPlugin('tool-plugin', PluginCategoryEnum.tool) + const modelPlugin = createPlugin('model-plugin', PluginCategoryEnum.model) + const toolParams = { + query: 'search', + category: PluginCategoryEnum.tool, + type: 'plugin' as const, + page_size: 40, + } + const modelParams = { + query: '', + category: PluginCategoryEnum.model, + type: 'plugin' as const, + page_size: 1000, + } + const toolQueryKey = ['marketplacePlugins', toolParams] + const modelQueryKey = ['marketplacePlugins', modelParams] + const toolQueryData = createInfiniteData(toolPlugin, toolParams.page_size) + const modelQueryData = createInfiniteData(modelPlugin, modelParams.page_size) + + queryClient.setQueryData(toolQueryKey, toolQueryData) + queryClient.setQueryData(modelQueryKey, modelQueryData) + + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins(), { + wrapper: createWrapperWithQueryClient(queryClient), + }) + + act(() => { + result.current.queryPlugins(toolParams) + }) + + await waitFor(() => { + expect(result.current.plugins).toEqual([toolPlugin]) + }) + + act(() => { + result.current.resetQueryParams() + }) + + expect(result.current.plugins).toBeUndefined() + expect(queryClient.getQueryData(toolQueryKey)).toEqual(toolQueryData) + expect(queryClient.getQueryData(modelQueryKey)).toEqual(modelQueryData) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx index 50e703aae4e..1d709af215b 100644 --- a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx @@ -17,16 +17,21 @@ vi.mock('@/utils/var', () => ({ const mockCollections = vi.fn() const mockCollectionPlugins = vi.fn() +const mockSearchAdvanced = vi.fn() vi.mock('@/service/client', () => ({ marketplaceClient: { collections: (...args: unknown[]) => mockCollections(...args), collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), + searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args), }, marketplaceQuery: { collections: { queryKey: (params: unknown) => ['marketplace', 'collections', params], }, + searchAdvanced: { + queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params], + }, }, })) @@ -50,6 +55,9 @@ describe('HydrateQueryClient', () => { mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] }, }) + mockSearchAdvanced.mockResolvedValue({ + data: { plugins: [], total: 0 }, + }) }) it('should render children within HydrationBoundary', async () => { @@ -119,7 +127,28 @@ describe('HydrateQueryClient', () => { expect(mockCollections).toHaveBeenCalled() }) - it('should not prefetch when category does not have collections (model)', async () => { + it('should prefetch plugin search when q is present', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all', q: 'openai' }), + children: <div>Child</div>, + }) + + expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalledWith( + expect.objectContaining({ + params: { kind: 'plugins' }, + body: expect.objectContaining({ + page: 1, + query: 'openai', + }), + }), + expect.any(Object), + ) + }) + + it('should prefetch when category does not have collections (model)', async () => { const { HydrateQueryClient } = await import('../hydration-server') await HydrateQueryClient({ @@ -128,9 +157,10 @@ describe('HydrateQueryClient', () => { }) expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalled() }) - it('should not prefetch when category does not have collections (bundle)', async () => { + it('should prefetch when category does not have collections (bundle)', async () => { const { HydrateQueryClient } = await import('../hydration-server') await HydrateQueryClient({ @@ -139,5 +169,6 @@ describe('HydrateQueryClient', () => { }) expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx index 8b78b7bba3c..32671514e43 100644 --- a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx @@ -1,10 +1,11 @@ -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Provider as JotaiProvider } from 'jotai' import { describe, expect, it, vi } from 'vite-plus/test' import { createNuqsTestWrapper } from '@/test/nuqs-testing' import PluginTypeSwitch from '../plugin-type-switch' +import styles from '../plugin-type-switch.module.css' vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') @@ -13,7 +14,7 @@ vi.mock('#i18n', async () => { } }) -const renderSwitch = (searchParams = '') => { +const renderSwitch = (searchParams = '', props?: ComponentProps<typeof PluginTypeSwitch>) => { const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> @@ -21,7 +22,7 @@ const renderSwitch = (searchParams = '') => { </JotaiProvider> ) - return { ...render(<PluginTypeSwitch />, { wrapper: Wrapper }), onUrlUpdate } + return { ...render(<PluginTypeSwitch {...props} />, { wrapper: Wrapper }), onUrlUpdate } } describe('PluginTypeSwitch', () => { @@ -41,7 +42,7 @@ describe('PluginTypeSwitch', () => { expect(screen.getByRole('button', { name: 'category.agents' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'category.triggers' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'category.extensions' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'category.bundles' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'category.bundles' })).not.toBeInTheDocument() }) it('updates the category in the URL when selected', async () => { @@ -56,4 +57,28 @@ describe('PluginTypeSwitch', () => { expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe('model') expect(modelsButton).toHaveAttribute('aria-pressed', 'true') }) + + it('exposes the selected category and updates the URL in the home variant', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderSwitch('?category=all', { variant: 'home' }) + const categoryGroup = screen.getByRole('group', { name: 'allCategories' }) + + expect(categoryGroup).toHaveClass('w-full', 'justify-start', 'gap-1') + const activeCategory = screen.getByRole('button', { name: 'category.all' }) + const inactiveCategory = screen.getByRole('button', { name: 'category.models' }) + + expect(activeCategory).toHaveAttribute('aria-pressed', 'true') + expect(activeCategory).toHaveClass(styles.homeItem!, styles.homeItemActive!) + expect(inactiveCategory).toHaveClass(styles.homeItem!) + expect(inactiveCategory).not.toHaveClass(styles.homeItemActive!) + expect(screen.getByRole('button', { name: 'categorySingle.datasource' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'categorySingle.agent' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'category.models' })) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)?.[0] + expect(update?.searchParams.get('category')).toBe('model') + expect(update?.options.scroll).toBe(false) + }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx index ec93fe23bde..9cf84fed0dc 100644 --- a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx @@ -163,7 +163,7 @@ describe('useMarketplacePlugins', () => { }) }) - it('should handle API error gracefully', async () => { + it('should surface API errors instead of an empty success', async () => { mockSearchAdvanced.mockRejectedValue(new Error('Network error')) const { useMarketplacePlugins } = await import('../query') @@ -177,11 +177,14 @@ describe('useMarketplacePlugins', () => { ) await waitFor(() => { - expect(result.current.data).toBeDefined() + expect(result.current.isError).toBe(true) }) - expect(result.current.data?.pages[0]!.plugins).toEqual([]) - expect(result.current.data?.pages[0]!.total).toBe(0) + // No synthesized page: an empty success let a backend outage render as + // "no plugins found", suppressed retries, and permanently disabled + // getNextPageParam for this key. + expect(result.current.data).toBeUndefined() + expect(result.current.error).toEqual(new Error('Network error')) }) it('should determine next page correctly via getNextPageParam', async () => { diff --git a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts index 62a786e5be1..7f0654e35f0 100644 --- a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts @@ -9,6 +9,7 @@ describe('marketplace search params', () => { ) expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('') expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([]) + expect(marketplaceSearchParamsParsers.languages.parseServerSide(undefined)).toEqual([]) }) it('parses supported query values with the configured parsers', () => { @@ -23,5 +24,9 @@ describe('marketplace search params', () => { 'rag', 'search', ]) + expect(marketplaceSearchParamsParsers.languages.parseServerSide('en,zh-Hans')).toEqual([ + 'en', + 'zh-Hans', + ]) }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx new file mode 100644 index 00000000000..34e350ac773 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx @@ -0,0 +1,65 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetchPluginBanners, mockGetLocaleOnServer } = vi.hoisted(() => ({ + mockFetchPluginBanners: vi.fn(), + mockGetLocaleOnServer: vi.fn(), +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: mockGetLocaleOnServer, +})) + +vi.mock('../home/banners', async (importOriginal) => { + const original = await importOriginal<typeof import('../home/banners')>() + + return { + ...original, + fetchPluginBanners: mockFetchPluginBanners, + } +}) + +vi.mock('../hydration-server', () => ({ + HydrateQueryClient: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('../view', () => ({ + MarketplaceView: ({ banners }: { banners: PluginBanner[] }) => ( + <p>Server banners: {banners.length}</p> + ), +})) + +describe('Marketplace server entry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prefetches localized homepage banners before rendering the standalone view', async () => { + mockGetLocaleOnServer.mockResolvedValue('en-US') + mockFetchPluginBanners.mockResolvedValue([ + { + id: 'banner-1', + title: 'Trending', + sort: 1, + language: 'en-US', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[]) + + const { default: Marketplace } = await import('../index') + const element = await Marketplace({ variant: 'home' }) + + render(element) + + expect(screen.getByText('Server banners: 1')).toBeInTheDocument() + expect(mockGetLocaleOnServer).toHaveBeenCalledOnce() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US') + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx index 03fd80dd333..a223d6524b0 100644 --- a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { Provider as JotaiProvider } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createNuqsTestWrapper } from '@/test/nuqs-testing' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' vi.mock('@/config', () => ({ API_PREFIX: '/api', @@ -116,6 +117,7 @@ describe('useMarketplaceData', () => { expect(result.current.plugins).toBeDefined() expect(result.current.pluginsTotal).toBeDefined() + expect(mockCollections).not.toHaveBeenCalled() document.body.removeChild(container) }) @@ -161,6 +163,35 @@ describe('useMarketplaceData', () => { document.body.removeChild(container) }) + it('should use the server route category for hydrated standalone search', async () => { + const { useMarketplaceData } = await import('../state') + const { Wrapper } = createWrapper('?q=openai') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(PLUGIN_TYPE_SEARCH_MAP.model), { + wrapper: Wrapper, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(mockSearchAdvanced).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + category: 'model', + query: 'openai', + }), + }), + expect.any(Object), + ) + + document.body.removeChild(container) + }) + it('should trigger scroll pagination via handlePageChange callback', async () => { // Return enough data to indicate hasNextPage (40 of 200 total) mockSearchAdvanced.mockResolvedValue({ @@ -287,4 +318,53 @@ describe('useMarketplaceData', () => { document.body.removeChild(container) }) + + // Regression: `isSearchMode` was derived from the raw URL value while the + // request body used the 500ms-debounced one. Keystroke #1 therefore flipped + // the hook into search mode with an empty query, firing a full search for '' + // whose generic top-plugins results rendered until the real ones replaced + // them — the wrong-results flash at the start of every search session. + it('should never issue an empty-query search when typing starts', async () => { + const { useMarketplaceData } = await import('../state') + const { useSearchPluginText } = await import('../atoms') + const { Wrapper } = createWrapper('?category=all') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook( + () => ({ + data: useMarketplaceData(), + setSearch: useSearchPluginText()[1], + }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.data.isLoading).toBe(false) + }) + + await act(async () => { + await result.current.setSearch('openai') + }) + + await waitFor( + () => { + expect(mockSearchAdvanced).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) + + expect(mockSearchAdvanced).not.toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ query: '' }) }), + expect.anything(), + ) + expect(mockSearchAdvanced).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ query: 'openai' }) }), + expect.anything(), + ) + + document.body.removeChild(container) + }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts index ec9e0b66772..546d16e7c3f 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -229,13 +229,14 @@ describe('getMarketplacePluginsByCollectionId', () => { expect(result).toHaveLength(2) }) - it('should handle fetch error and return empty array', async () => { + it('should propagate fetch errors', async () => { mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error')) const { getMarketplacePluginsByCollectionId } = await import('../utils') - const result = await getMarketplacePluginsByCollectionId('test-collection') - expect(result).toEqual([]) + await expect(getMarketplacePluginsByCollectionId('test-collection')).rejects.toThrow( + 'Network error', + ) }) it('should send an empty body when query is omitted', async () => { @@ -299,14 +300,35 @@ describe('getMarketplaceCollectionsAndPlugins', () => { expect(result.marketplaceCollectionPluginsMap).toBeDefined() }) - it('should handle fetch error and return empty data', async () => { + it('should propagate a failing collections request', async () => { mockCollections.mockRejectedValueOnce(new Error('Network error')) + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + + // Resolving an empty catalog here made a backend outage indistinguishable + // from "no collections", cached as a success for the whole staleTime. + await expect(getMarketplaceCollectionsAndPlugins()).rejects.toThrow('Network error') + }) + + it('should keep the catalog when a single collection fails', async () => { + mockCollections.mockResolvedValueOnce({ + data: { + collections: [ + { name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + { name: 'broken', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ], + }, + }) + mockCollectionPlugins + .mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } }) + .mockRejectedValueOnce(new Error('collection down')) + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') const result = await getMarketplaceCollectionsAndPlugins() - expect(result.marketplaceCollections).toEqual([]) - expect(result.marketplaceCollectionPluginsMap).toEqual({}) + expect(result.marketplaceCollections).toHaveLength(2) + expect(result.marketplaceCollectionPluginsMap.ok).toHaveLength(1) + expect(result.marketplaceCollectionPluginsMap.broken).toEqual([]) }) it('should append condition and type to URL when provided', async () => { @@ -431,23 +453,15 @@ describe('getMarketplacePlugins', () => { expect(call![0].body.category).toBe('') }) - it('should handle API error and return empty result', async () => { + it('should propagate API errors instead of synthesizing an empty page', async () => { mockSearchAdvanced.mockRejectedValueOnce(new Error('API error')) const { getMarketplacePlugins } = await import('../utils') - const result = await getMarketplacePlugins( - { - query: 'fail', - }, - 2, - ) - expect(result).toEqual({ - plugins: [], - total: 0, - page: 2, - page_size: 40, - }) + // A synthesized `{ plugins: [], total: 0 }` resolved as a *success*: no + // isError, no retry, a cached empty result, and getNextPageParam saw + // total 0 and killed pagination for that key permanently. + await expect(getMarketplacePlugins({ query: 'fail' }, 2)).rejects.toThrow('API error') }) it('should pass abort signal when provided', async () => { diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index a2118997a96..c01990d548a 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,9 +1,10 @@ import type { PluginsSort, SearchParamsFromCollection } from '@dify/contracts/marketplace' +import type { ActivePluginType } from './constants' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { useQueryState } from 'nuqs' -import { useCallback } from 'react' -import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { marketplaceSearchParamsParsers } from './search-params' +import { useCallback, useEffect } from 'react' +import { DEFAULT_SORT } from './constants' +import { marketplaceSearchParamsParsers, shouldSearchMarketplacePlugins } from './search-params' const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT) export function useMarketplaceSort() { @@ -21,6 +22,9 @@ export function useActivePluginType() { export function useFilterPluginTags() { return useQueryState('tags', marketplaceSearchParamsParsers.tags) } +export function useFilterTemplateLanguages() { + return useQueryState('languages', marketplaceSearchParamsParsers.languages) +} /** * Not all categories have collections, so we need to @@ -28,19 +32,48 @@ export function useFilterPluginTags() { */ export const searchModeAtom = atom<true | null>(null) -export function useMarketplaceSearchMode() { - const [searchPluginText] = useSearchPluginText() +export function useMarketplaceSearchMode( + activePluginTypeOverride?: ActivePluginType, + // Callers that debounce the query text MUST pass the debounced value here. + // Deciding "are we searching?" from the raw URL value while the request body + // carries the debounced one flips this hook true on keystroke #1, firing a + // wasted empty-query search whose generic top-plugins list renders for the + // debounce window before the real results replace it. '' is a meaningful + // override, so this is `??`, not `||`. + searchPluginTextOverride?: string, +) { + const [searchPluginTextFromUrl] = useSearchPluginText() + const searchPluginText = searchPluginTextOverride ?? searchPluginTextFromUrl const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() + const [activePluginTypeFromUrl] = useActivePluginType() + const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl const searchMode = useAtomValue(searchModeAtom) const isSearchMode = - !!searchPluginText || - filterPluginTags.length > 0 || - (searchMode ?? !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)) + searchMode === true || + shouldSearchMarketplacePlugins({ + category: activePluginType, + q: searchPluginText, + tags: filterPluginTags, + }) return isSearchMode } +/** + * The forced search mode lives in the app-wide Jotai store, so a "View More" + * click would otherwise leak into the next visit of the plugin catalog after + * navigating away (e.g. to /templates) and back, rendering empty-query search + * results instead of the prefetched collections. Reset it when the catalog + * route mounts; URL-owned state (q, tags, category) is not affected. + */ +export function useResetMarketplaceSearchModeOnMount() { + const setSearchMode = useSetAtom(searchModeAtom) + + useEffect(() => { + setSearchMode(null) + }, [setSearchMode]) +} + export function useMarketplaceMoreClick() { const [, setQ] = useSearchPluginText() const setSort = useSetAtom(marketplaceSortAtom) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 5db8045a547..9dda37bd3dc 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -5,6 +5,12 @@ export const DEFAULT_SORT = { sortOrder: 'DESC', } +/** + * DOM id of the marketplace scroll container. The route components render it + * and the scroll/viewport observers below the marketplace tree look it up. + */ +export const MARKETPLACE_CONTAINER_ID = 'marketplace-container' + export const SCROLL_BOTTOM_THRESHOLD = 100 export const PLUGIN_TYPE_SEARCH_MAP = { diff --git a/web/app/components/plugins/marketplace/creator-center-url.ts b/web/app/components/plugins/marketplace/creator-center-url.ts new file mode 100644 index 00000000000..33f2d3d5566 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-center-url.ts @@ -0,0 +1,48 @@ +import { useSyncExternalStore } from 'react' + +export const PUBLIC_CREATOR_CENTER_URL = 'https://creators.dify.ai/' + +const subscribe = () => () => {} + +/** + * marketplace.dify.ai → creators.dify.ai + * marketplace.dify.dev → creators.dify.dev + * marketplace-staging.dify.dev → creators-staging.dify.dev + */ +export const rewriteMarketplaceOriginToCreators = (origin: string): string | null => { + if (!origin) return null + + try { + const marketplaceUrl = new URL(origin) + const [service, ...domain] = marketplaceUrl.hostname.split('.') + if (!service?.startsWith('marketplace') || domain.length === 0) return null + + marketplaceUrl.hostname = [service.replace(/^marketplace/, 'creators'), ...domain].join('.') + marketplaceUrl.pathname = '/' + marketplaceUrl.search = '' + marketplaceUrl.hash = '' + return marketplaceUrl.toString() + } catch { + return null + } +} + +export const getCreatorCenterUrl = (marketplaceUrlPrefix: string, pageOrigin?: string): string => { + return ( + rewriteMarketplaceOriginToCreators(pageOrigin ?? '') || + rewriteMarketplaceOriginToCreators(marketplaceUrlPrefix) || + PUBLIC_CREATOR_CENTER_URL + ) +} + +/** + * Prefer the current page origin when this is the standalone Marketplace, so a + * .dev deployment cannot inherit a baked-in .ai Creator Center URL. + */ +export const useCreatorCenterUrl = (marketplaceUrlPrefix: string) => { + return useSyncExternalStore( + subscribe, + () => getCreatorCenterUrl(marketplaceUrlPrefix, window.location.origin), + () => getCreatorCenterUrl(marketplaceUrlPrefix), + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx new file mode 100644 index 00000000000..e81cb64ffa3 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx @@ -0,0 +1,50 @@ +import type { CreatorCreation } from '../model' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import CreationCard from '../creation-card' + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span data-testid="creation-icon" />, +})) + +const creation: CreatorCreation = { + id: 'plugin:dify/search', + kind: 'plugin', + title: 'Search', + description: 'Search the web.', + target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: 'search' }, + icon: { type: 'emoji', value: '🔎' }, + dependencyIcons: ['/one.png', '/two.png'], + dependencyCount: 4, + updatedAt: 1, + createdAt: 1, + popularity: 1, +} + +describe('CreationCard', () => { + it('renders a host link without selecting', () => { + render( + <CreationCard + creation={creation} + action={{ type: 'link', href: '/plugin/dify/search?language=en-US' }} + />, + ) + + expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute( + 'href', + '/plugin/dify/search?language=en-US', + ) + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('selects in Dify without rendering a navigation target', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render(<CreationCard creation={creation} action={{ type: 'select', onSelect }} />) + + await user.click(screen.getByRole('button', { name: 'Search' })) + expect(onSelect).toHaveBeenCalledOnce() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx new file mode 100644 index 00000000000..39306668d88 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx @@ -0,0 +1,94 @@ +import type { CreatorCreation } from '../model' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { renderWithNuqs } from '@/test/nuqs-testing' +import CreatorContent from '../creator-content' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.creatorProfile.creations': 'Creations', + 'marketplace.creatorProfile.sortBy': 'Sort by', + 'marketplace.creatorProfile.sort.updatedAt': 'Recently updated', + 'marketplace.creatorProfile.sort.createdAt': 'Recently created', + 'marketplace.creatorProfile.sort.popularity': 'Most popular', + 'marketplace.creatorProfile.sort.asc': 'Sort ascending', + 'marketplace.creatorProfile.sort.desc': 'Sort descending', + 'marketplace.creatorProfile.type.plugin': 'Plugin', + 'marketplace.creatorProfile.type.template': 'Template', + } + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span aria-hidden />, +})) + +const createCreation = ( + id: string, + title: string, + updatedAt: number, + createdAt: number, + popularity: number, +): CreatorCreation => ({ + id, + kind: 'plugin', + title, + description: `${title} description`, + target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: id }, + icon: { type: 'emoji', value: 'P' }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt, + createdAt, + popularity, +}) + +const creations = [ + createCreation('alpha', 'Alpha', 2, 3, 1), + createCreation('bravo', 'Bravo', 3, 1, 2), + createCreation('charlie', 'Charlie', 1, 2, 3), +] + +const cardNames = () => screen.getAllByRole('link').map((link) => link.getAttribute('aria-label')) + +describe('CreatorContent', () => { + it('writes sort into the URL and reorders the current cards', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs( + <CreatorContent + creations={creations} + getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })} + />, + ) + + expect(cardNames()).toEqual(['Bravo', 'Alpha', 'Charlie']) + + await user.click(screen.getByRole('button', { name: 'Sort by Recently updated' })) + const recentlyUpdatedOption = screen.getByRole('menuitemradio', { + name: 'Recently updated', + }) + const mostPopularOption = screen.getByRole('menuitemradio', { name: 'Most popular' }) + expect(recentlyUpdatedOption).toHaveAttribute('aria-checked', 'true') + expect(mostPopularOption).toHaveAttribute('aria-checked', 'false') + + await user.click(mostPopularOption) + await waitFor(() => { + expect(cardNames()).toEqual(['Charlie', 'Bravo', 'Alpha']) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity') + }) + + await user.click(screen.getByRole('button', { name: 'Sort ascending' })) + await waitFor(() => { + expect(cardNames()).toEqual(['Alpha', 'Bravo', 'Charlie']) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_order')).toBe('asc') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity') + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx new file mode 100644 index 00000000000..d2d7327502e --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx @@ -0,0 +1,83 @@ +import type { CreatorProfileViewModel } from '../model' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CreatorSidebar from '../creator-sidebar' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('../publisher-avatar', () => ({ + default: ({ className, size }: { className?: string; size?: number }) => ( + <div data-testid="publisher-avatar" data-size={size} className={className} /> + ), +})) + +const profile: CreatorProfileViewModel['profile'] = { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '', + backgroundUrl: '', + badges: [], + socialLinks: [ + { platform: 'website', href: 'https://example.com/', label: 'example.com' }, + { platform: 'x', href: 'https://x.com/creator', label: 'x.com/creator' }, + { + platform: 'instagram', + href: 'https://instagram.com/creator', + label: 'instagram.com/creator', + }, + { + platform: 'youtube', + href: 'https://youtube.com/creator', + label: 'youtube.com/creator', + }, + { platform: 'figma', href: 'https://figma.com/@creator', label: 'figma.com/@creator' }, + { platform: 'github', href: 'https://github.com/creator', label: 'github.com/creator' }, + ], +} + +describe('CreatorSidebar social links', () => { + it('adds a light shadow without changing the avatar geometry', () => { + render(<CreatorSidebar profile={profile} />) + + const avatar = screen.getByTestId('publisher-avatar') + + expect(avatar).toHaveClass('shadow-xs') + expect(avatar).toHaveClass( + 'absolute', + '-top-12', + '-left-2', + '!size-20', + 'border-[1.5px]', + 'md:-top-[68px]', + 'md:!size-[100px]', + ) + expect(avatar).toHaveAttribute('data-size', '100') + }) + + it('renders a static platform icon at the start of every social row', () => { + render(<CreatorSidebar profile={profile} />) + + const expectedClasses = [ + ['example.com', 'i-ri-global-line'], + ['x.com/creator', 'i-ri-twitter-x-fill'], + ['instagram.com/creator', 'i-ri-instagram-line'], + ['youtube.com/creator', 'i-ri-youtube-fill'], + ['figma.com/@creator', 'i-ri-figma-line'], + ['github.com/creator', 'i-ri-github-fill'], + ] + + for (const [name, iconClass] of expectedClasses) { + const link = screen.getByRole('link', { name }) + expect(link.firstElementChild).toHaveClass(iconClass!) + expect(link.firstElementChild).toHaveClass('size-4') + } + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts new file mode 100644 index 00000000000..c35b8ccc774 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts @@ -0,0 +1,261 @@ +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loadCreatorProfile } from '../data.server' + +const mocks = vi.hoisted(() => ({ + creatorDetail: vi.fn(), + organizationDetail: vi.fn(), + publisherPlugins: vi.fn(), + publisherTemplates: vi.fn(), +})) + +vi.mock('server-only', () => ({})) +vi.mock('@/config', () => ({ MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1' })) +vi.mock('@/service/client', () => ({ marketplaceClient: mocks })) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'search', + plugin_id: 'dify/search', + label: { en_US: 'Search' }, + brief: { en_US: 'Search the web.' }, + tags: [], +} as unknown as MarketplacePlugin + +const template = { + id: 'template-one', + template_name: 'Template one', + overview: 'Build an app.', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + usage_count: 1, + categories: [], +} as MarketplaceTemplate + +describe('loadCreatorProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.creatorDetail.mockResolvedValue({ + data: { + creator: { + unique_handle: 'creator', + display_name: 'Creator', + social_links: [], + }, + }, + }) + mocks.organizationDetail.mockResolvedValue({ data: {} }) + mocks.publisherPlugins.mockResolvedValue({ data: { plugins: [plugin] } }) + mocks.publisherTemplates.mockResolvedValue({ data: { templates: [template] } }) + }) + + it('loads individual data through all publisher contracts', async () => { + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-one', + locale: 'en-US', + }) + + expect(mocks.creatorDetail).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + }) + expect(mocks.publisherPlugins).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' }, + }) + expect(loaded?.viewModel.creations).toHaveLength(2) + expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined() + expect(loaded?.viewModel.profile.backgroundUrl).toBe('') + expect(loaded?.viewModel.profile.avatarUrl).toBe('') + }) + + it('only emits the remote background URL when the API reports an uploaded background', async () => { + mocks.creatorDetail.mockResolvedValue({ + data: { + creator: { + unique_handle: 'creator-with-background', + display_name: 'Creator with background', + background_image: 'creator/background.png', + social_links: [], + }, + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-with-background', + locale: 'en-US', + }) + + expect(loaded?.viewModel.profile.backgroundUrl).toBe( + 'https://marketplace.example/api/v1/creators/creator-with-background/background-image', + ) + }) + + it('only emits the remote avatar URL when the API reports an uploaded avatar', async () => { + mocks.creatorDetail.mockResolvedValue({ + data: { + creator: { + unique_handle: 'creator-with-avatar', + display_name: 'Creator with avatar', + avatar: 'creator/avatar.png', + social_links: [], + }, + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-with-avatar', + locale: 'en-US', + }) + + expect(loaded?.viewModel.profile.avatarUrl).toBe( + 'https://marketplace.example/api/v1/creators/creator-with-avatar/avatar', + ) + }) + + it('loads evanz from the Marketplace API without a development fixture branch', async () => { + await loadCreatorProfile({ uniqueHandle: 'evanz', locale: 'en-US' }) + + expect(mocks.creatorDetail).toHaveBeenCalledWith({ params: { uniqueHandle: 'evanz' } }) + expect(mocks.publisherTemplates).toHaveBeenCalledWith({ + params: { uniqueHandle: 'evanz' }, + query: { page: 1, page_size: 40, sort_by: 'updated_at', sort_order: 'DESC' }, + }) + }) + + it('forwards popularity sort to each publisher API column', async () => { + await loadCreatorProfile({ + uniqueHandle: 'creator-one', + locale: 'en-US', + sortBy: 'popularity', + sortOrder: 'asc', + }) + + expect(mocks.publisherPlugins).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + query: { page: 1, page_size: 40, sort_by: 'install_count', sort_order: 'ASC' }, + }) + expect(mocks.publisherTemplates).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + query: { page: 1, page_size: 40, sort_by: 'usage_count', sort_order: 'ASC' }, + }) + }) + + it('merge-sorts mixed creations after the publisher responses return', async () => { + mocks.publisherPlugins.mockResolvedValue({ + data: { + plugins: [{ ...plugin, install_count: 2, created_at: '2026-01-01T00:00:00Z' }], + }, + }) + mocks.publisherTemplates.mockResolvedValue({ + data: { + templates: [{ ...template, usage_count: 5, created_at: '2026-01-02T00:00:00Z' }], + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-one', + locale: 'en-US', + sortBy: 'popularity', + sortOrder: 'desc', + }) + + expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template', 'plugin']) + }) + + it('fetches remaining publisher pages until the reported total is loaded', async () => { + const extraPlugin = { + ...plugin, + name: 'extra', + plugin_id: 'dify/extra', + } as MarketplacePlugin + mocks.publisherPlugins + .mockResolvedValueOnce({ + data: { plugins: [plugin], total: 2 }, + }) + .mockResolvedValueOnce({ + data: { plugins: [extraPlugin], total: 2 }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'paged-creator', + locale: 'en-US', + }) + + expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(1, { + params: { uniqueHandle: 'paged-creator' }, + query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' }, + }) + expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(2, { + params: { uniqueHandle: 'paged-creator' }, + query: { page: 2, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' }, + }) + expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined() + expect(loaded?.pluginsByCreationId['plugin:dify/extra']).toBeDefined() + }) + + it('keeps successful creations when one publisher request fails', async () => { + mocks.publisherPlugins.mockRejectedValue(new Error('plugin request failed')) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-partial', + locale: 'en-US', + }) + + expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template']) + }) + + it('returns null when the primary creator does not exist', async () => { + mocks.creatorDetail.mockResolvedValue({ data: {} }) + + await expect( + loadCreatorProfile({ uniqueHandle: 'missing-creator', locale: 'en-US' }), + ).resolves.toBeNull() + }) + + it('rethrows when the primary creator request fails', async () => { + mocks.creatorDetail.mockRejectedValue(new Error('creator request timed out')) + + await expect( + loadCreatorProfile({ uniqueHandle: 'slow-creator', locale: 'en-US' }), + ).rejects.toThrow('creator request timed out') + }) + + it('rethrows when the organization request fails', async () => { + mocks.organizationDetail.mockRejectedValue(new Error('organization request timed out')) + + await expect( + loadCreatorProfile({ + uniqueHandle: 'slow-org', + publisherType: 'organization', + locale: 'en-US', + }), + ).rejects.toThrow('organization request timed out') + }) + + it('maps organizations to the shared creator profile shape', async () => { + mocks.organizationDetail.mockResolvedValue({ + data: { + organization: { + id: 'org-id', + unique_handle: 'dify-org', + display_name: 'Dify Org', + social_links: [], + }, + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'dify-org', + publisherType: 'organization', + locale: 'en-US', + }) + + expect(mocks.organizationDetail).toHaveBeenCalledWith({ params: { id: 'dify-org' } }) + expect(loaded?.viewModel.profile).toMatchObject({ + kind: 'organization', + displayName: 'Dify Org', + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx new file mode 100644 index 00000000000..7b9fad871c8 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx @@ -0,0 +1,229 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { MarketplaceSearchSelection } from '../../home/marketplace-search-autocomplete' +import type { LoadedCreatorProfile } from '../model' +import type { Plugin } from '@/app/components/plugins/types' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithNuqs } from '@/test/nuqs-testing' +import DifyCreatorProfile from '../dify-profile' + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + installedInfo: { 'dify/deep_research': { version: '0.0.1' } }, +})) + +const deepResearchPlugin = { + type: 'plugin', + org: 'dify', + name: 'deep_research', + plugin_id: 'dify/deep_research', + latest_package_identifier: 'dify/deep_research:0.0.1@test', + label: { 'en-US': 'Deep Research' }, + brief: { 'en-US': 'Research the web.' }, +} as unknown as Plugin + +const searchPlugin = { + ...deepResearchPlugin, + name: 'search_result', + plugin_id: 'dify/search_result', + latest_package_identifier: 'dify/search_result:0.0.1@test', + label: { 'en-US': 'Search result' }, +} as Plugin + +const template: MarketplaceTemplate = { + id: 'template-one', + template_name: 'Research Template', + overview: 'Build a research app.', + icon: 'R', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 1, + categories: [], +} + +const loadedProfile: LoadedCreatorProfile = { + viewModel: { + profile: { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '', + backgroundUrl: '', + badges: [], + socialLinks: [], + }, + creations: [ + { + id: 'plugin:dify/deep_research', + kind: 'plugin', + title: 'Deep Research', + description: 'Research the web.', + target: { + type: 'plugin', + pluginType: 'plugin', + org: 'dify', + name: 'deep_research', + }, + icon: { type: 'emoji', value: 'R' }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt: 1, + createdAt: 1, + popularity: 1, + }, + { + id: 'template:template-one', + kind: 'template', + title: 'Research Template', + description: 'Build a research app.', + target: { + type: 'template', + id: 'template-one', + publisher: 'dify', + templateName: 'Research Template', + }, + icon: { type: 'emoji', value: 'R' }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt: 1, + createdAt: 1, + popularity: 1, + }, + ], + }, + pluginsByCreationId: { + 'plugin:dify/deep_research': deepResearchPlugin, + }, + templatesByCreationId: { + 'template:template-one': template, + }, +} + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/app/components/main-nav/components/account-section', () => ({ + default: () => <div data-testid="account-section" />, +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span aria-hidden />, +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => ({ installedInfo: mocks.installedInfo }), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ manifest }: { manifest: { name: string } }) => ( + <div data-testid="install-plugin">{manifest.name}</div> + ), +})) + +vi.mock('../../detail-dialog', () => ({ + default: ({ + isInstalled, + onInstall, + plugin, + }: { + isInstalled: boolean + onInstall: () => void + plugin: { name: string } + }) => ( + <div role="dialog" aria-label="plugin-detail"> + <span>{plugin.name}</span> + <span>{isInstalled ? 'installed' : 'not installed'}</span> + <button type="button" onClick={onInstall}> + Install plugin + </button> + </div> + ), +})) + +vi.mock('../../templates/template-detail-dialog', () => ({ + default: ({ + onInstall, + template, + }: { + onInstall: () => void + template: { template_name: string } + }) => ( + <div role="dialog" aria-label="template-detail"> + <span>{template.template_name}</span> + <button type="button" onClick={onInstall}> + Install template + </button> + </div> + ), +})) + +vi.mock('../header', () => ({ + default: ({ + onSuggestionSelect, + }: { + onSuggestionSelect: (selection: MarketplaceSearchSelection) => void + }) => ( + <button + type="button" + onClick={() => { + onSuggestionSelect({ kind: 'plugin', plugin: searchPlugin }) + }} + > + Select search plugin + </button> + ), +})) + +describe('DifyCreatorProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the existing plugin detail flow with installed state', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + await user.click(screen.getByRole('button', { name: 'Deep Research' })) + + const dialog = screen.getByRole('dialog', { name: 'plugin-detail' }) + expect(dialog).toHaveTextContent('deep_research') + expect(dialog).toHaveTextContent('installed') + + await user.click(screen.getByRole('button', { name: 'Install plugin' })) + expect(screen.getByTestId('install-plugin')).toHaveTextContent('deep_research') + }) + + it('opens a template detail and imports it inside Dify', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + await user.click(screen.getByRole('button', { name: 'Research Template' })) + expect(screen.getByRole('dialog', { name: 'template-detail' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Install template' })) + expect(mocks.push).toHaveBeenCalledWith('/apps?template-id=template-one') + }) + + it('opens search results in the same plugin dialog controller', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + await user.click(screen.getByRole('button', { name: 'Select search plugin' })) + + const dialog = screen.getByRole('dialog', { name: 'plugin-detail' }) + expect(dialog).toHaveTextContent('search_result') + expect(dialog).toHaveTextContent('not installed') + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx new file mode 100644 index 00000000000..b8887a2b7c8 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CreatorProfileHeader from '../header' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.creatorProfile.searchPlaceholder': 'Search plugins or templates', + 'mainNav.marketplace': 'Marketplace', + } + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +vi.mock('../../home/home-guide', () => ({ + default: () => <div data-testid="marketplace-guide" />, +})) + +vi.mock('../../home/marketplace-search-autocomplete', () => ({ + MarketplaceSearchAutocomplete: () => <div data-testid="marketplace-search" />, +})) + +describe('CreatorProfileHeader', () => { + it('returns to the native Marketplace without marking a catalog tab active', () => { + render(<CreatorProfileHeader locale="en-US" onSuggestionSelect={vi.fn()} />) + + const pluginsLink = screen.getByRole('link', { name: 'Plugins' }) + const templatesLink = screen.getByRole('link', { name: 'Templates' }) + + expect(pluginsLink).toHaveAttribute('href', '/marketplace') + expect(pluginsLink).not.toHaveAttribute('aria-current') + expect(pluginsLink).not.toHaveClass('bg-state-base-active') + expect(templatesLink).not.toHaveAttribute('aria-current') + expect(templatesLink).not.toHaveClass('bg-state-base-active') + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts new file mode 100644 index 00000000000..ff8a507998c --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts @@ -0,0 +1,195 @@ +import type { + MarketplaceCreator, + MarketplacePlugin, + MarketplaceTemplate, +} from '@dify/contracts/marketplace' +import { describe, expect, it } from 'vitest' +import { + adaptCreatorProfile, + getStandaloneCreationHref, + normalizeCreatorSocialLink, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} from '../model' + +const creator: MarketplaceCreator = { + unique_handle: 'evanz', + display_name: 'Evan.Z', + social_links: ['github.com/evanz', 'javascript:alert(1)'], + badges: ['partner'], + verified: true, +} + +const plugin = { + type: 'bundle', + org: 'dify', + name: 'research', + labels: { en_US: 'Research bundle' }, + description: { en_US: 'Research reliably.' }, + install_count: 20, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-02-01T00:00:00Z', +} as unknown as MarketplacePlugin + +const template = { + id: 'template/one', + template_name: 'Research template', + overview: 'Start a research app.', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 10, + categories: [], + deps_plugins: ['dify/search'], + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-02-02T00:00:00Z', +} as MarketplaceTemplate + +describe('creator profile model', () => { + it('normalizes DTOs into host-neutral creation targets and safe social links', () => { + const viewModel = adaptCreatorProfile({ + creator, + kind: 'organization', + locale: 'en-US', + avatarUrl: '/avatar', + backgroundUrl: '/background', + plugins: [plugin], + templates: [template], + resolvePluginIcon: () => '/plugin-icon', + resolveTemplateIcon: () => '', + resolveDependencyIcon: (id) => `/dependency/${id}`, + }) + + expect(viewModel.profile.badges).toEqual(['partner', 'verified']) + expect(viewModel.profile.socialLinks).toEqual([ + expect.objectContaining({ platform: 'github', href: 'https://github.com/evanz' }), + ]) + expect(viewModel.creations[0]).toMatchObject({ + title: 'Research bundle', + target: { type: 'plugin', pluginType: 'bundle', org: 'dify', name: 'research' }, + }) + expect(viewModel.creations[1]).toMatchObject({ + target: { + type: 'template', + id: 'template/one', + publisher: 'dify', + templateName: 'Research template', + }, + dependencyCount: 1, + }) + }) + + it('builds standalone plugin, bundle, and template URLs outside the shared model', () => { + const viewModel = adaptCreatorProfile({ + creator, + kind: 'individual', + locale: 'en-US', + avatarUrl: '', + backgroundUrl: '', + plugins: [plugin], + templates: [template], + resolvePluginIcon: () => '', + resolveTemplateIcon: () => '', + resolveDependencyIcon: () => '', + }) + + expect(getStandaloneCreationHref(viewModel.creations[0]!, 'zh-Hans')).toBe( + '/bundles/dify/research?language=zh-Hans', + ) + expect(getStandaloneCreationHref(viewModel.creations[1]!, 'zh-Hans')).toBe( + '/template/dify/Research%20template?templateId=template%2Fone&creationType=templates&language=zh-Hans', + ) + }) + + it('normalizes Unix-second, Unix-millisecond, and ISO timestamps', () => { + const unixSeconds = 1_767_225_600 + const unixMilliseconds = 1_767_225_700_000 + const viewModel = adaptCreatorProfile({ + creator, + kind: 'individual', + locale: 'en-US', + avatarUrl: '', + backgroundUrl: '', + plugins: [ + { + ...plugin, + created_at: unixSeconds, + version_updated_at: unixSeconds + 100, + }, + ], + templates: [ + { + ...template, + created_at: '2026-01-02T00:00:00Z', + updated_at: unixMilliseconds, + }, + ], + resolvePluginIcon: () => '', + resolveTemplateIcon: () => '', + resolveDependencyIcon: () => '', + }) + + expect(viewModel.creations[0]).toMatchObject({ + createdAt: unixSeconds * 1000, + updatedAt: (unixSeconds + 100) * 1000, + }) + expect(viewModel.creations[1]).toMatchObject({ + createdAt: Date.parse('2026-01-02T00:00:00Z'), + updatedAt: unixMilliseconds, + }) + }) + + it('maps each UI sort onto the matching plugin and template API columns', () => { + expect(toPublisherSortQuery('updatedAt', 'desc')).toEqual({ + plugins: { sort_by: 'version_updated_at', sort_order: 'DESC' }, + templates: { sort_by: 'updated_at', sort_order: 'DESC' }, + }) + expect(toPublisherSortQuery('createdAt', 'asc')).toEqual({ + plugins: { sort_by: 'created_at', sort_order: 'ASC' }, + templates: { sort_by: 'created_at', sort_order: 'ASC' }, + }) + expect(toPublisherSortQuery('popularity', 'desc')).toEqual({ + plugins: { sort_by: 'install_count', sort_order: 'DESC' }, + templates: { sort_by: 'usage_count', sort_order: 'DESC' }, + }) + }) + + it('falls back to recently updated descending for unknown URL sort values', () => { + expect(parseCreatorSortField('garbage')).toBe('updatedAt') + expect(parseCreatorSortField(undefined)).toBe('updatedAt') + expect(parseCreatorSortOrder('sideways')).toBe('desc') + expect(parseCreatorSortOrder('ASC')).toBe('asc') + }) + + it('sorts all fields in both directions and preserves equal-value order', () => { + const creations = [ + { id: 'first', updatedAt: 1, createdAt: 3, popularity: 2 }, + { id: 'second', updatedAt: 1, createdAt: 2, popularity: 3 }, + { id: 'third', updatedAt: 2, createdAt: 1, popularity: 1 }, + ] as ReturnType<typeof adaptCreatorProfile>['creations'] + + expect(sortCreatorCreations(creations, 'updatedAt', 'asc').map(({ id }) => id)).toEqual([ + 'first', + 'second', + 'third', + ]) + expect(sortCreatorCreations(creations, 'createdAt', 'desc').map(({ id }) => id)).toEqual([ + 'first', + 'second', + 'third', + ]) + expect(sortCreatorCreations(creations, 'popularity', 'desc').map(({ id }) => id)).toEqual([ + 'second', + 'first', + 'third', + ]) + }) + + it('rejects unsafe URL schemes', () => { + expect(normalizeCreatorSocialLink('data:text/html,bad')).toBeNull() + expect(normalizeCreatorSocialLink('mailto:test@example.com')).toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx new file mode 100644 index 00000000000..ae7959e16aa --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx @@ -0,0 +1,64 @@ +import type { CreatorProfileViewModel } from '../model' +import { render } from 'vitest-browser-react' +import CreatorProfileView from '../view' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('../creator-sidebar', () => ({ + default: () => <aside>Creator sidebar</aside>, +})) + +vi.mock('../creator-content', () => ({ + default: () => ( + <section data-testid="creator-creations" style={{ height: 640, flexShrink: 0 }}> + Creator content + </section> + ), +})) + +const profile: CreatorProfileViewModel = { + profile: { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '', + backgroundUrl: '', + badges: [], + socialLinks: [], + }, + creations: [], +} + +describe('CreatorProfileView layout', () => { + it('keeps the profile background behind content taller than its scrollport', async () => { + const screen = await render( + <div + data-testid="creator-scrollport" + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + /> + </div>, + ) + + const scrollport = screen.getByTestId('creator-scrollport').element() + const profileRoot = scrollport.firstElementChild as HTMLElement + const creations = screen.getByTestId('creator-creations').element() + + expect(profileRoot.getBoundingClientRect().bottom).toBeGreaterThanOrEqual( + creations.getBoundingClientRect().bottom, + ) + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx new file mode 100644 index 00000000000..e6f5dec38c5 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx @@ -0,0 +1,88 @@ +import type { CreatorProfileViewModel } from '../model' +import { fireEvent, render } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import CreatorProfileView from '../view' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('../creator-sidebar', () => ({ + default: () => <aside>Creator sidebar</aside>, +})) + +vi.mock('../creator-content', () => ({ + default: () => <section>Creator content</section>, +})) + +const profile: CreatorProfileViewModel = { + profile: { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '/creator-avatar.png', + backgroundUrl: '/creator-background.png', + badges: [], + socialLinks: [], + }, + creations: [], +} + +describe('CreatorProfileView SSR background', () => { + it('includes the default background in server markup before the remote background loads', () => { + const markup = renderToStaticMarkup( + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + + expect(markup).toContain('default-background.png') + expect(markup).toContain('src="/creator-background.png"') + }) + + it('server-renders only the default background when the profile has no background', () => { + const markup = renderToStaticMarkup( + <CreatorProfileView + profile={{ + ...profile, + profile: { ...profile.profile, backgroundUrl: '' }, + }} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + + expect(markup).toContain('default-background.png') + expect(markup).not.toContain('<img') + expect(markup).toContain('border-0') + }) + + it('hides a stale remote image after a loading failure', () => { + const { container } = render( + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + const remoteBackground = container.querySelector<HTMLImageElement>( + 'img[src="/creator-background.png"]', + )! + + fireEvent.error(remoteBackground) + + expect(remoteBackground).toHaveAttribute('hidden') + expect(remoteBackground).toHaveClass('border-0') + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png b/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png new file mode 100644 index 00000000000..704fbae82e1 Binary files /dev/null and b/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png differ diff --git a/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx b/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx new file mode 100644 index 00000000000..72d62ba275e --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx @@ -0,0 +1,94 @@ +'use client' + +import type { CreatorCreation, CreatorCreationAction } from './model' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import AppIcon from '@/app/components/base/app-icon' +import CornerMark from '@/app/components/plugins/card/base/corner-mark' +import Link from '@/next/link' + +const MAX_VISIBLE_DEPENDENCIES = 7 + +type CreationCardProps = { + creation: CreatorCreation + action: CreatorCreationAction +} + +const cardClassName = + 'group relative flex h-[152px] min-w-0 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 text-left shadow-xs outline-hidden transition-shadow hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid' + +function CreationCardContent({ creation }: { creation: CreatorCreation }) { + const { t } = useTranslation() + const visibleDependencies = creation.dependencyIcons.slice(0, MAX_VISIBLE_DEPENDENCIES) + const remainingDependencies = Math.max(0, creation.dependencyCount - visibleDependencies.length) + + return ( + <> + <CornerMark + text={t(($) => $[`marketplace.creatorProfile.type.${creation.kind}`], { ns: 'plugin' })} + className={cn( + creation.kind === 'plugin' && '[&>div]:text-text-accent', + creation.kind === 'template' && '[&>div]:text-text-warning', + )} + /> + + <div className="flex min-w-0 shrink-0 items-center gap-3 px-4 pt-4 pr-20 pb-2"> + {creation.icon.type === 'image' ? ( + <AppIcon size="large" iconType="image" imageUrl={creation.icon.src} /> + ) : ( + <AppIcon + size="large" + iconType="emoji" + icon={creation.icon.value} + background={creation.icon.background} + /> + )} + <h3 className="min-w-0 flex-1 truncate system-md-medium text-text-primary"> + {creation.title} + </h3> + </div> + + <p className="mx-4 line-clamp-2 min-h-8 system-xs-regular text-text-secondary"> + {creation.description} + </p> + + <div className="mt-auto flex min-h-7 items-center gap-1 overflow-hidden px-4 py-1"> + {visibleDependencies.map((icon) => ( + <img + key={icon} + alt="" + aria-hidden + src={icon} + className="size-6 shrink-0 rounded-md border-[0.5px] border-effects-icon-border object-cover" + /> + ))} + {remainingDependencies > 0 && ( + <span className="shrink-0 system-xs-regular text-text-tertiary"> + +{remainingDependencies} + </span> + )} + </div> + </> + ) +} + +export default function CreationCard({ creation, action }: CreationCardProps) { + if (action.type === 'link') { + return ( + <Link href={action.href} aria-label={creation.title} className={cardClassName}> + <CreationCardContent creation={creation} /> + </Link> + ) + } + + return ( + <button + type="button" + aria-label={creation.title} + className={cardClassName} + onClick={action.onSelect} + > + <CreationCardContent creation={creation} /> + </button> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx new file mode 100644 index 00000000000..8665049314f --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx @@ -0,0 +1,156 @@ +'use client' + +import type { + CreatorCreation, + CreatorCreationAction, + CreatorSortField, + CreatorSortOrder, +} from './model' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { parseAsStringEnum, useQueryStates } from 'nuqs' +import { useMemo } from 'react' +import { useTranslation } from '#i18n' +import CreationCard from './creation-card' +import { + CREATOR_SORT_FIELDS, + DEFAULT_CREATOR_SORT_FIELD, + DEFAULT_CREATOR_SORT_ORDER, + sortCreatorCreations, +} from './model' + +type CreatorContentProps = { + creations: CreatorCreation[] + getCreationAction: (creation: CreatorCreation) => CreatorCreationAction +} + +const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false } +const creatorSortSearchParsers = { + sort_by: parseAsStringEnum<CreatorSortField>([...CREATOR_SORT_FIELDS]).withDefault( + DEFAULT_CREATOR_SORT_FIELD, + ), + sort_order: parseAsStringEnum<CreatorSortOrder>(['asc', 'desc']).withDefault( + DEFAULT_CREATOR_SORT_ORDER, + ), +} + +export default function CreatorContent({ creations, getCreationAction }: CreatorContentProps) { + const { t } = useTranslation() + const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions) + const sortField = sort.sort_by + const sortOrder = sort.sort_order + const sortOptions: Array<{ value: CreatorSortField; label: string }> = [ + { + value: 'updatedAt', + label: t(($) => $['marketplace.creatorProfile.sort.updatedAt'], { ns: 'plugin' }), + }, + { + value: 'createdAt', + label: t(($) => $['marketplace.creatorProfile.sort.createdAt'], { ns: 'plugin' }), + }, + { + value: 'popularity', + label: t(($) => $['marketplace.creatorProfile.sort.popularity'], { ns: 'plugin' }), + }, + ] + const selectedSort = sortOptions.find((option) => option.value === sortField) ?? sortOptions[0]! + const sortedCreations = useMemo( + () => sortCreatorCreations(creations, sortField, sortOrder), + [creations, sortField, sortOrder], + ) + const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc' + + return ( + <section + aria-labelledby="creator-creations-title" + className="flex min-w-0 flex-1 flex-col items-start pt-6" + > + <div className="flex w-full flex-wrap items-center justify-between gap-2"> + <h2 id="creator-creations-title" className="system-xl-semibold text-text-primary"> + {t(($) => $['marketplace.creatorProfile.creations'], { ns: 'plugin' })} + </h2> + + <div className="flex h-8 items-center"> + <DropdownMenu> + <DropdownMenuTrigger + aria-label={`${t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} ${selectedSort.label}`} + className="flex h-8 items-center rounded-lg px-2 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span className="mr-1 system-sm-regular text-text-tertiary"> + {t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} + </span> + <span className="system-sm-medium text-text-secondary">{selectedSort.label}</span> + <span aria-hidden className="ml-1 i-ri-arrow-down-s-line size-4 text-text-tertiary" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + className="min-w-[176px] p-1" + > + <DropdownMenuRadioGroup<CreatorSortField> + value={sortField} + onValueChange={(nextField) => { + void setSort({ sort_by: nextField, sort_order: sortOrder }) + }} + > + {sortOptions.map((option) => ( + <DropdownMenuRadioItem<CreatorSortField> + key={option.value} + value={option.value} + closeOnClick + className="justify-between px-3 pr-2 system-md-regular text-text-primary" + > + {option.label} + <DropdownMenuRadioItemIndicator className="ml-2" /> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + + <div className="mx-1 h-4 w-px bg-divider-regular" /> + <button + type="button" + aria-label={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], { + ns: 'plugin', + })} + title={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], { + ns: 'plugin', + })} + className="flex size-8 items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={() => { + void setSort({ sort_by: sortField, sort_order: nextSortOrder }) + }} + > + <span + aria-hidden + className={sortOrder === 'desc' ? 'i-ri-sort-desc size-4' : 'i-ri-sort-asc size-4'} + /> + </button> + </div> + </div> + + {sortedCreations.length > 0 ? ( + <div className="grid w-full grid-cols-1 gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3"> + {sortedCreations.map((creation) => ( + <CreationCard + key={creation.id} + creation={creation} + action={getCreationAction(creation)} + /> + ))} + </div> + ) : ( + <div className="w-full py-12 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })} + </div> + )} + </section> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx new file mode 100644 index 00000000000..0241983e870 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx @@ -0,0 +1,114 @@ +'use client' + +import type { CreatorProfileViewModel, CreatorSocialPlatform } from './model' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import Partner from '@/app/components/plugins/base/badges/partner' +import Verified from '@/app/components/plugins/base/badges/verified' +import PublisherAvatar from './publisher-avatar' + +type CreatorSidebarProps = { + profile: CreatorProfileViewModel['profile'] +} + +function SocialIcon({ platform }: { platform: CreatorSocialPlatform }) { + const className = 'size-4 shrink-0 text-text-tertiary' + + if (platform === 'x') return <span aria-hidden className={cn(className, 'i-ri-twitter-x-fill')} /> + if (platform === 'instagram') + return <span aria-hidden className={cn(className, 'i-ri-instagram-line')} /> + if (platform === 'youtube') + return <span aria-hidden className={cn(className, 'i-ri-youtube-fill')} /> + if (platform === 'figma') return <span aria-hidden className={cn(className, 'i-ri-figma-line')} /> + if (platform === 'github') + return <span aria-hidden className={cn(className, 'i-ri-github-fill')} /> + + return <span aria-hidden className={cn(className, 'i-ri-global-line')} /> +} + +export default function CreatorSidebar({ profile }: CreatorSidebarProps) { + const { t } = useTranslation() + const isOrganization = profile.kind === 'organization' + const isPartner = profile.badges.includes('partner') + const isVerified = profile.badges.includes('verified') + + return ( + <aside className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12"> + <PublisherAvatar + avatarUrl={profile.avatarUrl} + name={profile.displayName} + isOrganization={isOrganization} + size={100} + className={cn( + 'absolute -top-12 -left-2 z-10 !size-20 border-[1.5px] border-components-panel-bg bg-background-default-dodge shadow-xs md:-top-[68px] md:!size-[100px]', + isOrganization && 'rounded-[10px]', + )} + /> + + <div className="flex flex-col gap-1"> + <div className="flex flex-wrap items-center gap-1"> + <h1 className="title-2xl-semi-bold text-text-primary">{profile.displayName}</h1> + {isOrganization && ( + <span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary uppercase"> + {t(($) => $['marketplace.creatorProfile.organization'], { ns: 'plugin' })} + </span> + )} + {isPartner && ( + <Partner + className="size-[18px] shrink-0" + text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })} + /> + )} + {isVerified && ( + <Verified + className="size-[18px] shrink-0" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} + </div> + <span className="system-sm-regular text-text-tertiary">@{profile.handle}</span> + </div> + + {profile.description && ( + <p className="system-sm-regular whitespace-pre-wrap text-text-secondary"> + {profile.description} + </p> + )} + + {profile.email && ( + <a + href={`mailto:${profile.email}`} + className="flex min-w-0 items-center gap-1.5 py-1 system-sm-regular text-text-secondary outline-hidden hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" /> + <span className="truncate">{profile.email}</span> + </a> + )} + + {profile.socialLinks.length > 0 && ( + <div className="flex flex-col gap-2 py-1"> + <div className="flex w-full items-center gap-2"> + <span className="shrink-0 system-xs-medium text-text-tertiary uppercase"> + {t(($) => $['marketplace.creatorProfile.onTheWeb'], { ns: 'plugin' })} + </span> + <div className="h-px min-w-0 flex-1 bg-gradient-to-r from-divider-regular to-transparent" /> + </div> + <div className="flex flex-col gap-2"> + {profile.socialLinks.map((link) => ( + <a + key={link.href} + href={link.href} + target="_blank" + rel="noopener noreferrer" + className="flex min-w-0 items-center gap-1.5 system-sm-regular text-text-secondary outline-hidden transition-colors hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <SocialIcon platform={link.platform} /> + <span className="truncate">{link.label}</span> + </a> + ))} + </div> + </div> + )} + </aside> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/data.server.ts b/web/app/components/plugins/marketplace/creator-profile/data.server.ts new file mode 100644 index 00000000000..6de6c8a4df2 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/data.server.ts @@ -0,0 +1,201 @@ +import type { + MarketplaceCreator, + MarketplaceOrganization, + MarketplacePlugin, + MarketplaceTemplate, +} from '@dify/contracts/marketplace' +import type { CreatorSortField, CreatorSortOrder, LoadedCreatorProfile } from './model' +import { cache } from 'react' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { marketplaceClient } from '@/service/client' +import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils' +import { + adaptCreatorProfile, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} from './model' +import 'server-only' + +const PAGE_SIZE = 40 +const MAX_PAGES = 5 + +const fetchAllPublisherPages = async <T>( + fetchPage: (page: number) => Promise<{ items: T[]; total?: number }>, +) => { + const first = await fetchPage(1) + const items = [...first.items] + const total = first.total ?? items.length + + for (let page = 2; page <= MAX_PAGES && items.length < total; page++) { + const next = await fetchPage(page) + if (next.items.length === 0) break + items.push(...next.items) + } + + return items +} + +const mapOrganizationToCreator = ( + organization: MarketplaceOrganization, + uniqueHandle: string, +): MarketplaceCreator => ({ + id: organization.id || organization.name, + email: organization.email, + name: organization.name || organization.display_name || uniqueHandle, + display_name: organization.display_name || organization.name || uniqueHandle, + unique_handle: organization.unique_handle || uniqueHandle, + display_email: organization.display_email, + description: organization.description, + avatar: organization.avatar, + background_image: organization.background_image, + social_links: organization.social_links ?? [], + badges: organization.badges, + verified: organization.verified, + status: organization.status, + created_at: organization.created_at, + updated_at: organization.updated_at, +}) + +const getPublisher = async (uniqueHandle: string, publisherType?: string) => { + if (publisherType === 'organization') { + const response = await marketplaceClient.organizationDetail({ + params: { id: uniqueHandle }, + }) + const organization = response.data?.organization + return organization ? mapOrganizationToCreator(organization, uniqueHandle) : undefined + } + + const response = await marketplaceClient.creatorDetail({ + params: { uniqueHandle }, + }) + return response.data?.creator +} + +const getPublisherPlugins = async ( + uniqueHandle: string, + sortField: CreatorSortField, + sortOrder: CreatorSortOrder, +) => { + const { plugins } = toPublisherSortQuery(sortField, sortOrder) + return fetchAllPublisherPages(async (page) => { + const response = await marketplaceClient.publisherPlugins({ + params: { uniqueHandle }, + query: { page, page_size: PAGE_SIZE, ...plugins }, + }) + return { + items: response.data?.plugins ?? [], + total: response.data?.total, + } + }) +} + +const getPublisherTemplates = async ( + uniqueHandle: string, + sortField: CreatorSortField, + sortOrder: CreatorSortOrder, +) => { + const { templates } = toPublisherSortQuery(sortField, sortOrder) + return fetchAllPublisherPages(async (page) => { + const response = await marketplaceClient.publisherTemplates({ + params: { uniqueHandle }, + query: { page, page_size: PAGE_SIZE, ...templates }, + }) + return { + items: response.data?.templates ?? [], + total: response.data?.total, + } + }) +} + +const getTemplateIcon = (template: MarketplaceTemplate) => + template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon` + : '' + +const getDependencyIcon = (pluginId: string) => + `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon` + +const loadCreatorProfileCached = cache( + async ( + uniqueHandle: string, + publisherType: string | undefined, + locale: string, + sortField: CreatorSortField, + sortOrder: CreatorSortOrder, + ): Promise<LoadedCreatorProfile | null> => { + const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([ + getPublisher(uniqueHandle, publisherType), + getPublisherPlugins(uniqueHandle, sortField, sortOrder), + getPublisherTemplates(uniqueHandle, sortField, sortOrder), + ]) + + if (creatorResult.status === 'rejected') throw creatorResult.reason + const creator = creatorResult.value + if (!creator) return null + + const plugins: MarketplacePlugin[] = + pluginsResult.status === 'fulfilled' ? pluginsResult.value : [] + const templates: MarketplaceTemplate[] = + templatesResult.status === 'fulfilled' ? templatesResult.value : [] + const kind = publisherType === 'organization' ? 'organization' : 'individual' + const resource = kind === 'organization' ? 'organizations' : 'creators' + const encodedHandle = encodeURIComponent(uniqueHandle) + const backgroundUrl = creator.background_image + ? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/background-image` + : '' + const avatarUrl = creator.avatar + ? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/avatar` + : '' + const viewModel = adaptCreatorProfile({ + creator, + kind, + locale, + avatarUrl, + backgroundUrl, + plugins, + templates, + resolvePluginIcon: getPluginIconInMarketplace, + resolveTemplateIcon: getTemplateIcon, + resolveDependencyIcon: getDependencyIcon, + }) + + return { + viewModel: { + ...viewModel, + creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder), + }, + pluginsByCreationId: Object.fromEntries( + plugins.map((plugin) => [ + `${plugin.type}:${plugin.org}/${plugin.name}`, + getFormattedPlugin(plugin), + ]), + ), + templatesByCreationId: Object.fromEntries( + templates.map((template) => [`template:${template.id}`, template]), + ), + } + }, +) + +export const loadCreatorProfile = ({ + uniqueHandle, + publisherType, + locale, + sortBy, + sortOrder, +}: { + uniqueHandle: string + publisherType?: string + locale: string + sortBy?: string + sortOrder?: string +}) => + loadCreatorProfileCached( + uniqueHandle, + publisherType, + locale, + parseCreatorSortField(sortBy), + parseCreatorSortOrder(sortOrder), + ) diff --git a/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx new file mode 100644 index 00000000000..39b8d53c8cd --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx @@ -0,0 +1,142 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete' +import type { CreatorCreation, LoadedCreatorProfile } from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { useMemo, useState } from 'react' +import AccountSection from '@/app/components/main-nav/components/account-section' +import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' +import { useRouter } from '@/next/navigation' +import MarketplaceDetailDialog from '../detail-dialog' +import TemplateDetailDialog from '../templates/template-detail-dialog' +import { getFormattedPlugin } from '../utils' +import CreatorProfileHeader from './header' +import CreatorProfileView from './view' + +type SelectedCreation = + | { kind: 'plugin'; plugin: Plugin } + | { kind: 'template'; template: MarketplaceTemplate } + +type DifyCreatorProfileProps = { + loadedProfile: LoadedCreatorProfile + locale: string +} + +const normalizePlugin = (plugin: Plugin): Plugin => ({ + ...plugin, + label: plugin.label ?? {}, + brief: plugin.brief ?? {}, + description: plugin.description ?? {}, + tags: plugin.tags ?? [], + badges: plugin.badges ?? null, +}) + +export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) { + const router = useRouter() + const [selected, setSelected] = useState<SelectedCreation | null>(null) + const [pluginToInstall, setPluginToInstall] = useState<Plugin | null>(null) + const profilePlugins = Object.values(loadedProfile.pluginsByCreationId) + const pluginIds = useMemo( + () => + Array.from( + new Set([ + ...profilePlugins.map((plugin) => plugin.plugin_id), + ...(selected?.kind === 'plugin' ? [selected.plugin.plugin_id] : []), + ]), + ).sort(), + [profilePlugins, selected], + ) + const { installedInfo } = useCheckInstalled({ + pluginIds, + enabled: pluginIds.length > 0, + }) + + const selectCreation = (creation: CreatorCreation) => { + if (creation.kind === 'plugin') { + const plugin = loadedProfile.pluginsByCreationId[creation.id] + if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) }) + return + } + + const template = loadedProfile.templatesByCreationId[creation.id] + if (template) setSelected({ kind: 'template', template }) + } + + const selectSearchResult = (selection: MarketplaceSearchSelection) => { + if (selection.kind === 'plugin') { + setSelected({ + kind: 'plugin', + plugin: normalizePlugin(getFormattedPlugin(selection.plugin)), + }) + return + } + setSelected({ kind: 'template', template: selection.template }) + } + + const closeSelected = () => setSelected(null) + const selectedPlugin = selected?.kind === 'plugin' ? selected.plugin : null + const selectedTemplate = selected?.kind === 'template' ? selected.template : null + + return ( + <> + <CreatorProfileView + profile={loadedProfile.viewModel} + homeHref="/marketplace" + isMarketplacePlatform + getCreationAction={(creation) => ({ + type: 'select', + onSelect: () => selectCreation(creation), + })} + header={ + <CreatorProfileHeader + locale={locale} + onSuggestionSelect={selectSearchResult} + actions={ + <div className="p-0.5"> + <AccountSection compact /> + </div> + } + /> + } + /> + + {selectedPlugin && ( + <MarketplaceDetailDialog + isInstalled={Boolean(installedInfo?.[selectedPlugin.plugin_id])} + open + plugin={selectedPlugin} + onInstall={() => { + setPluginToInstall(selectedPlugin) + closeSelected() + }} + onOpenChange={(open) => { + if (!open) closeSelected() + }} + /> + )} + {selectedTemplate && ( + <TemplateDetailDialog + open + template={selectedTemplate} + onInstall={() => { + closeSelected() + router.push(`/apps?template-id=${encodeURIComponent(selectedTemplate.id)}`) + }} + onOpenChange={(open) => { + if (!open) closeSelected() + }} + /> + )} + {pluginToInstall && ( + <InstallFromMarketplace + manifest={pluginToInstall} + uniqueIdentifier={pluginToInstall.latest_package_identifier} + onClose={() => setPluginToInstall(null)} + onSuccess={() => setPluginToInstall(null)} + /> + )} + </> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/header.tsx b/web/app/components/plugins/marketplace/creator-profile/header.tsx new file mode 100644 index 00000000000..038c7d052c4 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/header.tsx @@ -0,0 +1,82 @@ +'use client' + +import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete' +import { cn } from '@langgenius/dify-ui/cn' +import { useState } from 'react' +import { useTranslation } from '#i18n' +import Link from '@/next/link' +import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg' +import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg' +import HomeCatalogTabs from '../home/home-catalog-tabs' +import HomeGuide from '../home/home-guide' +import styles from '../home/home-sticky.module.css' +import { MarketplaceSearchAutocomplete } from '../home/marketplace-search-autocomplete' + +type CreatorProfileHeaderProps = { + actions?: React.ReactNode + locale: string + onSuggestionSelect: (selection: MarketplaceSearchSelection) => void +} + +export default function CreatorProfileHeader({ + actions, + locale, + onSuggestionSelect, +}: CreatorProfileHeaderProps) { + const { t } = useTranslation() + const [searchValue, setSearchValue] = useState('') + + return ( + <header className="sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-4 border-b border-divider-regular bg-background-default px-4 md:px-6"> + <div className="flex min-w-0 flex-1 items-center gap-4"> + <Link + href="/marketplace" + aria-label="Dify Marketplace" + className="flex h-full w-[141.933px] shrink-0 items-center" + > + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoLight, + )} + src={MarketplaceLogo.src} + /> + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoDark, + )} + src={MarketplaceLogoDark.src} + /> + </Link> + <div className="hidden md:block"> + <HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} /> + </div> + </div> + + <div className="hidden w-80 shrink-0 md:block"> + <MarketplaceSearchAutocomplete + locale={locale} + onSuggestionSelect={onSuggestionSelect} + onValueChange={setSearchValue} + placeholder={t(($) => $['marketplace.creatorProfile.searchPlaceholder'], { + ns: 'plugin', + })} + scope="all" + value={searchValue} + /> + </div> + + <div className="flex min-w-0 flex-1 items-center justify-end gap-2.5"> + <div className="hidden md:block"> + <HomeGuide isMarketplacePlatform={false} /> + </div> + {actions} + </div> + </header> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/model.ts b/web/app/components/plugins/marketplace/creator-profile/model.ts new file mode 100644 index 00000000000..d76b770a8f1 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/model.ts @@ -0,0 +1,319 @@ +import type { + MarketplaceCreator, + MarketplacePlugin, + MarketplaceTemplate, + MarketplaceTimestamp, +} from '@dify/contracts/marketplace' +import type { Plugin } from '@/app/components/plugins/types' + +type CreatorProfileKind = 'individual' | 'organization' +type CreatorProfileBadge = 'partner' | 'verified' +export type CreatorSocialPlatform = 'website' | 'x' | 'instagram' | 'youtube' | 'figma' | 'github' +export type CreatorSortField = 'updatedAt' | 'createdAt' | 'popularity' +export type CreatorSortOrder = 'asc' | 'desc' +export const CREATOR_SORT_FIELDS = ['updatedAt', 'createdAt', 'popularity'] as const +export const DEFAULT_CREATOR_SORT_FIELD: CreatorSortField = 'updatedAt' +export const DEFAULT_CREATOR_SORT_ORDER: CreatorSortOrder = 'desc' + +export const parseCreatorSortField = (value?: string | null): CreatorSortField => + CREATOR_SORT_FIELDS.includes(value as CreatorSortField) + ? (value as CreatorSortField) + : DEFAULT_CREATOR_SORT_FIELD + +export const parseCreatorSortOrder = (value?: string | null): CreatorSortOrder => { + const normalized = value?.toLowerCase() + return normalized === 'asc' || normalized === 'desc' ? normalized : DEFAULT_CREATOR_SORT_ORDER +} + +export const toPublisherSortQuery = (field: CreatorSortField, order: CreatorSortOrder) => { + const sort_order = order === 'asc' ? 'ASC' : 'DESC' + return { + plugins: { + sort_by: + field === 'updatedAt' + ? 'version_updated_at' + : field === 'createdAt' + ? 'created_at' + : 'install_count', + sort_order, + }, + templates: { + sort_by: + field === 'updatedAt' ? 'updated_at' : field === 'createdAt' ? 'created_at' : 'usage_count', + sort_order, + }, + } +} + +export type CreatorSocialLink = { + platform: CreatorSocialPlatform + href: string + label: string +} + +type CreatorCreationTarget = + | { + type: 'plugin' + org: string + name: string + pluginType: MarketplacePlugin['type'] + } + | { + type: 'template' + id: string + publisher: string + templateName: string + } + +type CreatorCreationIcon = + | { type: 'image'; src: string } + | { type: 'emoji'; value: string; background?: string } + +export type CreatorCreation = { + id: string + kind: 'plugin' | 'template' + title: string + description: string + target: CreatorCreationTarget + icon: CreatorCreationIcon + dependencyIcons: string[] + dependencyCount: number + updatedAt: number + createdAt: number + popularity: number +} + +export type CreatorProfileViewModel = { + profile: { + kind: CreatorProfileKind + displayName: string + handle: string + description?: string + email?: string + avatarUrl: string + backgroundUrl: string + badges: CreatorProfileBadge[] + socialLinks: CreatorSocialLink[] + } + creations: CreatorCreation[] +} + +export type LoadedCreatorProfile = { + viewModel: CreatorProfileViewModel + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> +} + +export type CreatorCreationAction = + | { type: 'link'; href: string } + | { type: 'select'; onSelect: () => void } + +export type CreatorProfileAdapterInput = { + creator: MarketplaceCreator + kind: CreatorProfileKind + locale: string + avatarUrl: string + backgroundUrl: string + plugins: MarketplacePlugin[] + templates: MarketplaceTemplate[] + resolvePluginIcon: (plugin: MarketplacePlugin) => string + resolveTemplateIcon: (template: MarketplaceTemplate) => string + resolveDependencyIcon: (pluginId: string) => string +} + +const toTimestamp = (value?: MarketplaceTimestamp | null) => { + if (value === undefined || value === null || value === '') return 0 + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return 0 + + // Marketplace search responses use Unix seconds, while some consumers may already + // provide JavaScript timestamps in milliseconds. + return Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value + } + + const timestamp = Date.parse(value) + return Number.isNaN(timestamp) ? 0 : timestamp +} + +const getCreatorLocalizedText = ( + value: Partial<Record<string, string>> | string | undefined, + locale: string, +) => { + if (typeof value === 'string') return value + if (!value) return '' + + const normalizedLocale = locale.replace('-', '_') + return ( + value[locale] || + value[normalizedLocale] || + value['en-US'] || + value.en_US || + Object.values(value).find(Boolean) || + '' + ) +} + +const getSocialPlatform = (hostname: string): CreatorSocialPlatform => { + if ( + hostname === 'x.com' || + hostname.endsWith('.x.com') || + hostname === 'twitter.com' || + hostname.endsWith('.twitter.com') + ) + return 'x' + if (hostname === 'instagram.com' || hostname.endsWith('.instagram.com')) return 'instagram' + if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com') || hostname === 'youtu.be') + return 'youtube' + if (hostname === 'figma.com' || hostname.endsWith('.figma.com')) return 'figma' + if (hostname === 'github.com' || hostname.endsWith('.github.com')) return 'github' + return 'website' +} + +export const normalizeCreatorSocialLink = (value: string): CreatorSocialLink | null => { + const trimmedValue = value.trim() + if (!trimmedValue) return null + + const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(trimmedValue) + if (hasScheme && !/^https?:\/\//i.test(trimmedValue)) return null + + try { + const url = new URL( + /^https?:\/\//i.test(trimmedValue) ? trimmedValue : `https://${trimmedValue}`, + ) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + + const hostname = url.hostname.toLowerCase().replace(/^www\./, '') + return { + platform: getSocialPlatform(hostname), + href: url.toString(), + label: trimmedValue.replace(/^https?:\/\//i, '').replace(/\/$/, ''), + } + } catch { + return null + } +} + +const getCreatorBadges = (creator: MarketplaceCreator) => { + const badges = new Set<CreatorProfileBadge>() + if (creator.badges?.includes('partner')) badges.add('partner') + if (creator.verified || creator.badges?.includes('verified')) badges.add('verified') + return Array.from(badges) +} + +export const adaptCreatorProfile = ({ + creator, + kind, + locale, + avatarUrl, + backgroundUrl, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, +}: CreatorProfileAdapterInput): CreatorProfileViewModel => { + const pluginCreations = plugins.map((plugin): CreatorCreation => ({ + id: `${plugin.type}:${plugin.org}/${plugin.name}`, + kind: 'plugin', + title: getCreatorLocalizedText(plugin.labels ?? plugin.label, locale) || plugin.name, + description: + getCreatorLocalizedText( + plugin.type === 'bundle' ? plugin.description : plugin.brief, + locale, + ) || + plugin.introduction || + '', + target: { + type: 'plugin', + org: plugin.org, + name: plugin.name, + pluginType: plugin.type, + }, + icon: { type: 'image', src: resolvePluginIcon(plugin) }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt: toTimestamp(plugin.version_updated_at || plugin.updated_at), + createdAt: toTimestamp(plugin.created_at), + popularity: plugin.install_count || 0, + })) + + const templateCreations = templates.map((template): CreatorCreation => { + const templateIcon = resolveTemplateIcon(template) + const dependencyIds = template.deps_plugins ?? [] + const publisher = + template.publisher_handle || + template.publisher_unique_handle || + template.creator_email || + 'template' + + return { + id: `template:${template.id}`, + kind: 'template', + title: template.template_name, + description: template.overview || '', + target: { + type: 'template', + id: template.id, + publisher, + templateName: template.template_name, + }, + icon: templateIcon + ? { type: 'image', src: templateIcon } + : { type: 'emoji', value: template.icon || '📄', background: template.icon_background }, + dependencyIcons: dependencyIds.map(resolveDependencyIcon), + dependencyCount: dependencyIds.length, + updatedAt: toTimestamp(template.updated_at), + createdAt: toTimestamp(template.created_at), + popularity: template.usage_count || 0, + } + }) + + return { + profile: { + kind, + displayName: creator.display_name || creator.name || creator.unique_handle, + handle: creator.unique_handle, + description: creator.description || undefined, + email: creator.display_email || creator.email || undefined, + avatarUrl, + backgroundUrl, + badges: getCreatorBadges(creator), + socialLinks: (creator.social_links ?? []) + .map(normalizeCreatorSocialLink) + .filter((link): link is CreatorSocialLink => link !== null), + }, + creations: [...pluginCreations, ...templateCreations], + } +} + +export const sortCreatorCreations = ( + creations: CreatorCreation[], + field: CreatorSortField, + order: CreatorSortOrder, +) => { + const direction = order === 'asc' ? 1 : -1 + return creations + .map((creation, index) => ({ creation, index })) + .sort((left, right) => { + const difference = (left.creation[field] - right.creation[field]) * direction + return difference || left.index - right.index + }) + .map(({ creation }) => creation) +} + +export const getStandaloneCreationHref = (creation: CreatorCreation, locale?: string) => { + const language = locale ? `language=${encodeURIComponent(locale)}` : '' + if (creation.target.type === 'plugin') { + const resource = creation.target.pluginType === 'bundle' ? 'bundles' : 'plugin' + const path = `/${resource}/${encodeURIComponent(creation.target.org)}/${encodeURIComponent(creation.target.name)}` + return language ? `${path}?${language}` : path + } + + const params = new URLSearchParams({ + templateId: creation.target.id, + creationType: 'templates', + }) + if (locale) params.set('language', locale) + return `/template/${encodeURIComponent(creation.target.publisher)}/${encodeURIComponent(creation.target.templateName)}?${params.toString()}` +} diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx b/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx new file mode 100644 index 00000000000..2776410cd51 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { CSSProperties } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useState } from 'react' + +type PublisherAvatarProps = { + avatarUrl: string + name: string + isOrganization: boolean + size?: number + className?: string +} + +// Keep in sync with Creator Center `components/ui/avatar.tsx`. +const DEFAULT_AVATAR_BG = + 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%), linear-gradient(90deg, #155aef 0%, #155aef 100%)' + +const DEFAULT_AVATAR_LETTER_STYLE: CSSProperties = { + color: '#FFFFFF', + textShadow: '0px 0.25px 0.5px rgba(0, 0, 0, 0.20)', + lineHeight: '120%', + textTransform: 'uppercase', +} + +function getFallbackTextClass(size: number) { + if (size <= 32) return 'text-xs' + if (size <= 50) return 'text-base' + return 'text-[40px]' +} + +export default function PublisherAvatar({ + avatarUrl, + name, + isOrganization, + size = 24, + className, +}: PublisherAvatarProps) { + const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null) + const shapeClass = isOrganization ? 'rounded-md' : 'rounded-full' + const shouldShowImage = Boolean(avatarUrl) && failedAvatarUrl !== avatarUrl + const fallbackLetter = name?.[0]?.toUpperCase() || 'U' + + return ( + <div + style={{ width: size, height: size }} + className={cn( + 'relative shrink-0 overflow-hidden border-[0.5px] border-divider-regular', + shapeClass, + className, + )} + > + {shouldShowImage ? ( + <img + src={avatarUrl} + alt={name} + className={cn('size-full object-cover', shapeClass)} + onError={() => setFailedAvatarUrl(avatarUrl)} + /> + ) : ( + <div + className={cn('flex size-full items-center justify-center', shapeClass)} + style={{ background: DEFAULT_AVATAR_BG }} + > + <span + className={cn(getFallbackTextClass(size), 'font-semibold')} + style={DEFAULT_AVATAR_LETTER_STYLE} + > + {fallbackLetter} + </span> + </div> + )} + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/view.tsx b/web/app/components/plugins/marketplace/creator-profile/view.tsx new file mode 100644 index 00000000000..ebae19c187e --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/view.tsx @@ -0,0 +1,88 @@ +'use client' + +import type { ReactNode } from 'react' +import type { CreatorCreation, CreatorCreationAction, CreatorProfileViewModel } from './model' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import Link from '@/next/link' +import DefaultCreatorBackground from './assets/default-background.png' +import CreatorContent from './creator-content' +import CreatorSidebar from './creator-sidebar' + +export type CreatorProfileViewProps = { + profile: CreatorProfileViewModel + getCreationAction: (creation: CreatorCreation) => CreatorCreationAction + header?: ReactNode + homeHref: string + isMarketplacePlatform: boolean +} + +export default function CreatorProfileView({ + profile, + getCreationAction, + header, + homeHref, + isMarketplacePlatform, +}: CreatorProfileViewProps) { + const { t } = useTranslation() + + return ( + <div className="flex min-h-full shrink-0 flex-col bg-background-default"> + {header} + <main + className={cn( + 'flex w-full flex-1 flex-col px-4', + isMarketplacePlatform ? 'md:px-6' : 'md:px-9', + )} + > + <nav + aria-label={t(($) => $['marketplace.creatorProfile.breadcrumbLabel'], { ns: 'plugin' })} + className="flex h-12 shrink-0 items-end gap-2 overflow-hidden" + > + <Link + href={homeHref} + aria-label={t(($) => $['marketplace.creatorProfile.home'], { ns: 'plugin' })} + className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span aria-hidden className="i-ri-home-4-line size-4" /> + </Link> + <span aria-hidden className="pb-0.5 system-md-regular text-text-quaternary"> + / + </span> + <span className="pb-0.5 system-md-regular text-text-primary"> + {t(($) => $['marketplace.creatorProfile.title'], { ns: 'plugin' })} + </span> + </nav> + + <div className="w-full pt-5 pb-8"> + <div + className="relative h-40 w-full overflow-hidden rounded-xl border-0 bg-cover bg-center bg-no-repeat md:h-60" + style={{ backgroundImage: `url("${DefaultCreatorBackground.src}")` }} + > + {profile.profile.backgroundUrl && ( + <img + alt="" + aria-hidden + src={profile.profile.backgroundUrl} + className="size-full border-0 object-cover object-center" + onError={(event) => { + event.currentTarget.hidden = true + }} + /> + )} + </div> + + <div + className={cn( + 'grid min-w-0 grid-cols-1 gap-8 md:grid-cols-[234px_minmax(0,1fr)]', + isMarketplacePlatform ? 'md:pl-4' : 'md:pl-9', + )} + > + <CreatorSidebar profile={profile.profile} /> + <CreatorContent creations={profile.creations} getCreationAction={getCreationAction} /> + </div> + </div> + </main> + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index d4dc268ab93..af8a7ea12ef 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -7,6 +7,7 @@ import { useLocale, useTranslation } from '#i18n' import Divider from '@/app/components/base/divider' import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations' +import { MARKETPLACE_CONTAINER_ID } from '../constants' import PluginTypeSwitch from '../plugin-type-switch' import SearchBoxWrapper from '../search-box/search-box-wrapper' @@ -27,7 +28,7 @@ const EXPANDED_TABS_MARGIN_TOP = 32 const Description = ({ isMarketplacePlatform = false, marketplaceNav, - scrollContainerId = 'marketplace-container', + scrollContainerId = MARKETPLACE_CONTAINER_ID, }: DescriptionProps) => { const { t } = useTranslation('plugin') const { t: tCommon } = useTranslation('common') diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx new file mode 100644 index 00000000000..d141b88d519 --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx @@ -0,0 +1,125 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ThemeProvider } from 'next-themes' +import { describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import MarketplaceDetailDialog from '../index' + +vi.mock('../../utils', () => ({ + getPluginLinkInMarketplace: ( + plugin: Plugin, + params: { installed: string; language: string; source?: string; theme?: string; view: string }, + ) => + `about:blank?plugin=${plugin.org}/${plugin.name}&installed=${params.installed}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`, +})) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'plugin-a', + plugin_id: 'plugin-a', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Plugin A' }, + brief: { 'en-US': 'Brief' }, + description: { 'en-US': 'Description' }, + introduction: 'Intro', + repository: 'https://github.com/dify/plugin-a', + category: PluginCategoryEnum.tool, + install_count: 42, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +} as Plugin + +describe('MarketplaceDetailDialog', () => { + it('renders the marketplace detail route in modal mode and closes in place', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog + open + isInstalled + plugin={plugin} + onInstall={vi.fn()} + onOpenChange={onOpenChange} + /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail') + expect(frame).toHaveAttribute( + 'src', + // resolvedTheme maps the "system" preference to the concrete value, so + // the embedded detail page receives light/dark rather than "system". + 'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal', + ) + expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('forwards a validated install request from the embedded detail frame', () => { + const onInstall = vi.fn() + + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog + open + isInstalled={false} + plugin={plugin} + onInstall={onInstall} + onOpenChange={vi.fn()} + /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const installRequest = { + type: 'dify-marketplace:install-plugin', + pluginUniqueIdentifier: plugin.latest_package_identifier, + } + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: 'https://attacker.example', + source: frame.contentWindow, + }), + ) + fireEvent( + window, + new MessageEvent('message', { + data: { + ...installRequest, + pluginUniqueIdentifier: 'another/plugin:1.0.0', + }, + origin: 'null', + source: frame.contentWindow, + }), + ) + expect(onInstall).not.toHaveBeenCalled() + + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: 'null', + source: frame.contentWindow, + }), + ) + + expect(onInstall).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/plugins/marketplace/detail-dialog/frame.tsx b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx new file mode 100644 index 00000000000..529f6ffff9c --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx @@ -0,0 +1,133 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { + Dialog, + DialogBackdrop, + DialogClose, + DialogPopup, + DialogPortal, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' + +type MarketplaceDetailDialogFrameProps = { + open: boolean + src: string + title: string + onMessage?: (data: unknown) => void + onOpenChange: (open: boolean) => void +} + +// The iframe load event can be delayed indefinitely on a stalled connection +// (and cross-origin load errors are not observable), so reveal the frame after +// this timeout instead of keeping the skeleton up forever. +const LOADING_REVEAL_TIMEOUT_MS = 15_000 + +export default function MarketplaceDetailDialogFrame({ + open, + src, + title, + onMessage, + onOpenChange, +}: MarketplaceDetailDialogFrameProps) { + const { t } = useTranslation() + const iframeRef = useRef<HTMLIFrameElement>(null) + const closeButtonRef = useRef<HTMLButtonElement>(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + if (!open) return + + const timeout = window.setTimeout(() => setIsLoading(false), LOADING_REVEAL_TIMEOUT_MS) + return () => window.clearTimeout(timeout) + }, [open, src]) + + useEffect(() => { + if (!open || !onMessage) return + + const marketplaceOrigin = new URL(src, window.location.href).origin + const handleMessage = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow || event.origin !== marketplaceOrigin) + return + + onMessage(event.data) + } + + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [onMessage, open, src]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setIsLoading(true) + onOpenChange(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogPortal> + <DialogBackdrop /> + {/* Keep initial focus on the visible close control: while the iframe is + still loading it is inert, so default focus could otherwise land on + an invisible cross-origin frame. */} + <DialogPopup + initialFocus={closeButtonRef} + className="fixed top-1/2 left-1/2 h-[min(800px,calc(100dvh-48px))] w-[min(1200px,calc(100vw-48px))] -translate-x-1/2 -translate-y-1/2 overflow-hidden border-0 p-0 shadow-xl" + > + <DialogTitle className="sr-only">{title}</DialogTitle> + <div + aria-hidden + className={cn( + 'absolute inset-0 bg-background-default transition-opacity', + isLoading ? 'opacity-100' : 'pointer-events-none opacity-0', + )} + > + <div className="flex h-[52px] items-center px-6"> + <div className="h-4 w-40 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + </div> + <div className="mx-auto flex w-full max-w-[1000px] gap-8 px-12 py-8"> + <div className="flex flex-1 flex-col gap-4"> + <div className="h-16 w-2/3 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-4 w-full animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-4 w-5/6 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + <div className="mt-8 h-72 w-full animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + </div> + <div className="hidden w-60 flex-col gap-4 lg:flex"> + <div className="h-24 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-52 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + </div> + </div> + </div> + <iframe + ref={iframeRef} + // While loading, remove the invisible frame from focus, pointer, + // and accessibility interaction until its content is presentable. + inert={isLoading} + className={cn( + 'size-full border-0 bg-background-default transition-opacity', + isLoading ? 'pointer-events-none opacity-0' : 'opacity-100', + )} + onLoad={() => setIsLoading(false)} + referrerPolicy="strict-origin-when-cross-origin" + src={src} + title={title} + /> + <DialogClose + render={ + <IconButton + ref={closeButtonRef} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + size="sm" + className="absolute top-5 right-5 size-8 rounded-lg" + > + <span aria-hidden className="i-ri-close-line size-4" /> + </IconButton> + } + /> + </DialogPopup> + </DialogPortal> + </Dialog> + ) +} diff --git a/web/app/components/plugins/marketplace/detail-dialog/index.tsx b/web/app/components/plugins/marketplace/detail-dialog/index.tsx new file mode 100644 index 00000000000..8f764cdd19c --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/index.tsx @@ -0,0 +1,70 @@ +'use client' + +import type { Plugin } from '@/app/components/plugins/types' +import { useTheme } from 'next-themes' +import { useCallback } from 'react' +import { useLocale, useTranslation } from '#i18n' +import { getPluginLinkInMarketplace } from '../utils' +import MarketplaceDetailDialogFrame from './frame' + +const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-plugin' + +type MarketplaceDetailDialogProps = { + isInstalled: boolean + open: boolean + plugin: Plugin + onInstall: () => void + onOpenChange: (open: boolean) => void +} + +function MarketplaceDetailDialog({ + isInstalled, + open, + plugin, + onInstall, + onOpenChange, +}: MarketplaceDetailDialogProps) { + const { t } = useTranslation() + const locale = useLocale() + // resolvedTheme maps the "system" preference to the concrete light/dark + // value the marketplace page expects. + const { resolvedTheme } = useTheme() + const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name + const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' }) + const detailURL = getPluginLinkInMarketplace(plugin, { + installed: String(isInstalled), + language: locale, + source: globalThis.location?.origin, + theme: resolvedTheme, + view: 'modal', + }) + + const handleMessage = useCallback( + (data: unknown) => { + if ( + typeof data !== 'object' || + data === null || + !('type' in data) || + !('pluginUniqueIdentifier' in data) || + data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE || + data.pluginUniqueIdentifier !== plugin.latest_package_identifier + ) + return + + onInstall() + }, + [onInstall, plugin.latest_package_identifier], + ) + + return ( + <MarketplaceDetailDialogFrame + open={open} + src={detailURL} + title={`${pluginLabel} · ${detailLabel}`} + onMessage={isInstalled ? undefined : handleMessage} + onOpenChange={onOpenChange} + /> + ) +} + +export default MarketplaceDetailDialog diff --git a/web/app/components/plugins/marketplace/embedded.tsx b/web/app/components/plugins/marketplace/embedded.tsx new file mode 100644 index 00000000000..297d6504579 --- /dev/null +++ b/web/app/components/plugins/marketplace/embedded.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { MarketplaceViewProps } from './view' +import { queryOptions, useQuery } from '@tanstack/react-query' +import { useLocale } from '@/context/i18n' +import { useResetMarketplaceSearchModeOnMount } from './atoms' +import { fetchPluginBanners } from './home/banners' +import { MarketplaceView } from './view' + +const BANNER_STALE_TIME = 1000 * 60 * 5 + +export type EmbeddedMarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & { + initialBanners?: PluginBanner[] + /** + * Locale used to fetch `initialBanners` during server rendering. `initialBanners` + * is only applied while the client locale still matches it, so a client-side + * language change refetches banners instead of seeding the new locale's cache + * with banners from the previous language. + */ + initialLocale?: string +} + +export function EmbeddedMarketplace({ + initialBanners, + initialLocale, + variant = 'default', + ...props +}: EmbeddedMarketplaceProps) { + useResetMarketplaceSearchModeOnMount() + const locale = useLocale() + const { data: banners = [] } = useQuery( + queryOptions({ + // fetchPluginBanners returns normalized PluginBanner[] rather than the + // raw contract response, so it uses its own cache key instead of + // impersonating the generated banners.list contract query. + queryKey: ['marketplace-banners', locale], + queryFn: () => fetchPluginBanners(locale), + enabled: variant === 'home', + initialData: locale === initialLocale ? initialBanners : undefined, + staleTime: BANNER_STALE_TIME, + }), + ) + + return <MarketplaceView {...props} banners={banners} variant={variant} /> +} diff --git a/web/app/components/plugins/marketplace/filter-track-link.tsx b/web/app/components/plugins/marketplace/filter-track-link.tsx new file mode 100644 index 00000000000..acb520df383 --- /dev/null +++ b/web/app/components/plugins/marketplace/filter-track-link.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { ComponentProps } from 'react' +import Link from '@/next/link' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' + +type MarketplaceFilterTrackLinkProps = ComponentProps<typeof Link> & { + filterValue: string + filterType: 'type_tab' | 'category' | 'language' + selectedValues: string[] + selectionMode?: 'single' | 'multi' + trackFilter?: boolean +} + +export default function MarketplaceFilterTrackLink({ + filterValue, + filterType, + selectedValues, + selectionMode = 'single', + trackFilter = true, + onClick, + ...props +}: MarketplaceFilterTrackLinkProps) { + return ( + <Link + {...props} + onClick={(event) => { + if (trackFilter) { + markMarketplaceSiteFilter({ + filter_type: filterType, + selection_mode: selectionMode, + filter_value: filterValue, + selected_values: selectedValues, + }) + } + onClick?.(event) + }} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/README.md b/web/app/components/plugins/marketplace/home/README.md new file mode 100644 index 00000000000..2284732297e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/README.md @@ -0,0 +1,12 @@ +# Marketplace Catalog Home + +The redesigned Marketplace catalog shell provides the shared header, hero, search, trending, tabs, and sticky category navigation used by the Plugins and Templates pages. + +## Internal Modules + +- `marketplace/list/list-wrapper` +- `marketplace/plugin-type-switch` + +## External Modules + +None. diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx new file mode 100644 index 00000000000..79571b30f3e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx @@ -0,0 +1,32 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithNuqs } from '@/test/nuqs-testing' +import CatalogLanguagesFilter from '../catalog-languages-filter' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), + }), + } +}) + +describe('CatalogLanguagesFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes selected languages into the URL', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs(<CatalogLanguagesFilter />) + await user.click(screen.getByRole('button', { name: 'plugin.marketplace.languages' })) + await user.click(screen.getByRole('checkbox', { name: '中文' })) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages')).toBe('zh-Hans') + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx new file mode 100644 index 00000000000..97bdd8e1ce5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx @@ -0,0 +1,47 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithNuqs } from '@/test/nuqs-testing' +import CatalogTagsFilter from '../catalog-tags-filter' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), + }), + } +}) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + ], + tagsMap: { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, + }, + }), +})) + +describe('CatalogTagsFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes selected tags into the URL', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs(<CatalogTagsFilter />) + await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' })) + await user.click(screen.getByRole('checkbox', { name: 'Agent' })) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags')).toBe('agent') + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts new file mode 100644 index 00000000000..e1236270d32 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + EMBEDDED_MOBILE_BANNER_MEDIA, + MARKETPLACE_MOBILE_BANNER_MEDIA, + marketplaceTabletBannerMedia, + resolveEventAdBannerImageSrcs, +} from '../event-ad-banner-image' + +describe('resolveEventAdBannerImageSrcs', () => { + it('uses the mobile asset on the mobile slot when one exists', () => { + expect( + resolveEventAdBannerImageSrcs({ + desktop: '/desktop.png', + tablet: '/tablet.png', + mobile: '/mobile.png', + }), + ).toEqual({ + desktop: '/desktop.png', + mobile: '/mobile.png', + tablet: '/tablet.png', + }) + }) + + it('falls back to desktop on the mobile slot when mobile is missing', () => { + expect( + resolveEventAdBannerImageSrcs({ + desktop: '/desktop.png', + tablet: '/tablet.png', + }), + ).toEqual({ + desktop: '/desktop.png', + mobile: '/desktop.png', + tablet: '/tablet.png', + }) + }) + + it('omits tablet when the banner has no tablet asset', () => { + expect( + resolveEventAdBannerImageSrcs({ + desktop: '/desktop.png', + mobile: '/mobile.png', + }), + ).toEqual({ + desktop: '/desktop.png', + mobile: '/mobile.png', + tablet: undefined, + }) + }) +}) + +describe('marketplaceTabletBannerMedia', () => { + it('keeps tablet out of the standalone mobile breakpoint', () => { + expect(MARKETPLACE_MOBILE_BANNER_MEDIA).toBe('(max-width: 879px)') + expect(marketplaceTabletBannerMedia(true)).toBe('(min-width: 880px) and (max-width: 1023px)') + }) + + it('keeps tablet out of the embedded mobile breakpoint', () => { + expect(EMBEDDED_MOBILE_BANNER_MEDIA).toBe('(max-width: 639px)') + expect(marketplaceTabletBannerMedia(false)).toBe('(min-width: 640px) and (max-width: 1023px)') + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx new file mode 100644 index 00000000000..3e1f3117bd4 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx @@ -0,0 +1,41 @@ +import { render } from 'vitest-browser-react' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeCatalogTabs from '../home-catalog-tabs' +import { HomeStickyStateProvider } from '../home-sticky-state-provider' +import styles from '../home-sticky.module.css' + +describe('Marketplace home catalog alignment', () => { + it('aligns catalog tabs and filters with the content container', async () => { + const screen = await render( + <HomeStickyStateProvider> + <div className="w-[1200px]" data-marketplace-standalone> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={ + <div data-testid="catalog-filter" role="group" aria-label="Categories" /> + } + catalogTabs={ + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + } + /> + <div className={`px-8 ${styles.catalogContent}`}> + <div role="region" aria-label="Catalog content" className="h-10" /> + </div> + </div> + </HomeStickyStateProvider>, + ) + + const contentLeft = screen + .getByRole('region', { name: 'Catalog content' }) + .element() + .getBoundingClientRect().left + const tabsLeft = screen.getByRole('navigation').element().getBoundingClientRect().left + const filtersLeft = screen.getByTestId('catalog-filter').element().getBoundingClientRect().left + + expect(tabsLeft).toBeCloseTo(contentLeft) + expect(filtersLeft).toBeCloseTo(contentLeft) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx new file mode 100644 index 00000000000..25b68060190 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx @@ -0,0 +1,241 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeCatalogTabs from '../home-catalog-tabs' +import { + HOME_HEADER_HEIGHT_PX, + HOME_SEARCH_HEIGHT_PX, + HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, +} from '../home-constants' +import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider' +import styles from '../home-sticky.module.css' + +describe('Marketplace catalog tab handoff', () => { + it('hands off only when the in-flow tabs fully reach the sticky header', async () => { + await page.viewport(1200, 800) + const screen = await render( + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <div + data-testid="catalog-header" + style={{ + position: 'sticky', + top: 0, + zIndex: 50, + display: 'flex', + height: 48, + flexShrink: 0, + alignItems: 'center', + background: 'white', + }} + > + <HomeStickyCatalogTabs> + <div className={styles.headerCatalogTabs} data-testid="header-catalog-tabs"> + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + </div> + </HomeStickyCatalogTabs> + </div> + <div style={{ height: 220, flexShrink: 0 }} /> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div data-testid="catalog-categories">Categories</div>} + catalogTabs={ + <div data-testid="content-catalog-tabs"> + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + </div> + } + /> + <div data-testid="following-content" style={{ height: 640, flexShrink: 0 }} /> + </div> + </HomeStickyStateProvider>, + ) + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByTestId('catalog-header').element() + const navigation = screen.getByRole('region').element() + const categories = screen.getByTestId('catalog-categories').element() + const contentTabsSlot = screen.getByTestId('content-catalog-tabs').element().parentElement! + const contentTabsRegion = contentTabsSlot.parentElement! + const headerTabsSlot = screen.getByTestId('header-catalog-tabs').element().parentElement! + const followingContent = screen.getByTestId('following-content').element() as HTMLElement + const initialHeight = navigation.getBoundingClientRect().height + const initialCategoryOffset = + categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top + const initialHeaderSlotWidth = headerTabsSlot.getBoundingClientRect().width + const initialHeaderSlotHeight = headerTabsSlot.getBoundingClientRect().height + const initialFollowingOffset = followingContent.offsetTop + const initialScrollHeight = scrollContainer.scrollHeight + const contentPluginsLink = + contentTabsSlot.querySelector<HTMLAnchorElement>('a[href="/plugins"]')! + const headerPluginsLink = headerTabsSlot.querySelector<HTMLAnchorElement>('a[href="/plugins"]')! + + expect(initialHeaderSlotWidth).toBeGreaterThan(0) + expect(initialHeaderSlotHeight).toBeGreaterThan(0) + expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('none') + expect(getComputedStyle(headerTabsSlot).transitionProperty).toBe('opacity, transform') + expect(getComputedStyle(headerTabsSlot).transitionDuration).toBe('0.14s') + + contentPluginsLink.focus() + expect(document.activeElement).toBe(contentPluginsLink) + + const handoffScrollTop = + scrollContainer.scrollTop + + contentTabsRegion.getBoundingClientRect().bottom - + header.getBoundingClientRect().bottom + scrollContainer.scrollTop = handoffScrollTop - 1 + scrollContainer.dispatchEvent(new Event('scroll')) + await new Promise<void>((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ) + + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1) + expect( + contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom, + ).toBeCloseTo(1) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(document.activeElement).toBe(contentPluginsLink) + + scrollContainer.scrollTop = handoffScrollTop + scrollContainer.dispatchEvent(new Event('scroll')) + await vi.waitFor(() => { + expect(navigation).toHaveClass(styles.catalogNavigationPinned!) + }) + + expect(scrollContainer.scrollTop).toBe(handoffScrollTop) + expect(navigation.getBoundingClientRect().height).toBeCloseTo(initialHeight) + expect( + categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top, + ).toBeCloseTo(initialCategoryOffset) + expect( + categories.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + ).toBeCloseTo(64) + expect( + contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom, + ).toBeCloseTo(0) + expect(headerTabsSlot.getBoundingClientRect().width).toBeCloseTo(initialHeaderSlotWidth) + expect(headerTabsSlot.getBoundingClientRect().height).toBeCloseTo(initialHeaderSlotHeight) + expect(followingContent.offsetTop).toBe(initialFollowingOffset) + expect(scrollContainer.scrollHeight).toBe(initialScrollHeight) + expect(getComputedStyle(contentTabsSlot).display).not.toBe('none') + expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('none') + expect(getComputedStyle(contentTabsSlot).transitionProperty).toBe('opacity, transform') + expect(getComputedStyle(contentTabsSlot).transitionDuration).toBe('0.14s') + expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(contentTabsSlot).toHaveAttribute('inert') + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('auto') + await vi.waitFor( + () => { + expect(getComputedStyle(contentTabsSlot).opacity).toBe('0') + expect(getComputedStyle(headerTabsSlot).opacity).toBe('1') + expect(document.activeElement).toBe(headerPluginsLink) + }, + { timeout: 500 }, + ) + + scrollContainer.scrollTop = handoffScrollTop - 1 + scrollContainer.dispatchEvent(new Event('scroll')) + await vi.waitFor(() => { + expect(document.activeElement).toBe(contentPluginsLink) + }) + + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1) + expect( + contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom, + ).toBeCloseTo(1) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + }) + + it('keeps the in-flow tabs active when the standalone header slot is hidden on mobile', async () => { + await page.viewport(879, 800) + const screen = await render( + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <div style={{ display: 'flex', height: 48, flexShrink: 0 }}> + <HomeStickyCatalogTabs> + <div className={styles.headerCatalogTabs} data-testid="mobile-header-tabs"> + Header tabs + </div> + </HomeStickyCatalogTabs> + </div> + <div style={{ height: 220, flexShrink: 0 }} /> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div>Categories</div>} + catalogTabs={<div data-testid="mobile-content-tabs">Content tabs</div>} + /> + <div style={{ height: 640, flexShrink: 0 }} /> + </div> + </HomeStickyStateProvider>, + ) + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const navigation = screen.getByRole('region').element() + const contentTabsSlot = screen.getByTestId('mobile-content-tabs').element().parentElement! + const headerTabs = screen.getByTestId('mobile-header-tabs').element() + const headerTabsSlot = headerTabs.parentElement! + + expect(getComputedStyle(headerTabs).display).toBe('none') + + scrollContainer.scrollTop = 300 + scrollContainer.dispatchEvent(new Event('scroll')) + + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(getComputedStyle(contentTabsSlot).opacity).toBe('1') + expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('auto') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect( + contentTabsSlot.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + ).toBeCloseTo( + HOME_HEADER_HEIGHT_PX + + HOME_SEARCH_HEIGHT_PX + + HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX /* .catalogTabsRegion padding-top, tucked under search padding */, + ) + + await page.viewport(880, 800) + await vi.waitFor(() => { + expect(navigation).toHaveClass(styles.catalogNavigationPinned!) + }) + expect(getComputedStyle(headerTabs).display).toBe('flex') + expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(contentTabsSlot).toHaveAttribute('inert') + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + + await page.viewport(879, 800) + await vi.waitFor(() => { + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + }) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx new file mode 100644 index 00000000000..ecb121795f3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx @@ -0,0 +1,320 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeCatalogTabs from '../home-catalog-tabs' +import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider' +import styles from '../home-sticky.module.css' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), + }), + } +}) + +vi.mock('../../plugin-type-switch', () => ({ + default: ({ className, variant }: { className?: string; variant?: string }) => ( + <div data-testid="plugin-type-switch" className={className} data-variant={variant} /> + ), +})) + +afterEach(() => { + document.querySelectorAll('#marketplace-container').forEach((element) => element.remove()) +}) + +describe('HomeCatalogNavigation', () => { + const renderNavigation = (isMarketplacePlatform: boolean) => { + return render( + <HomeStickyStateProvider> + <HomeStickyCatalogTabs> + <div data-testid="header-catalog-tabs" /> + </HomeStickyCatalogTabs> + <HomeCatalogNavigation + isMarketplacePlatform={isMarketplacePlatform} + catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />} + /> + </HomeStickyStateProvider>, + ) + } + + it('keeps template navigation inside the Marketplace platform', () => { + renderNavigation(true) + + const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' }) + + expect(navigationSection).toHaveClass(styles.catalogNavigation!) + expect(navigationSection.firstElementChild).toHaveClass('w-full') + expect(navigationSection.firstElementChild).not.toHaveClass('mx-auto', 'max-w-[1200px]') + const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + expect(activeTab).toHaveAttribute('aria-current', 'page') + expect(activeTab).toHaveAttribute('href', '/plugins') + expect(activeTab).toHaveClass('bg-state-base-active') + expect(activeTab).not.toHaveClass('text-text-accent') + expect(activeTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect( + screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }), + ).toHaveAttribute('href', '/templates') + expect(screen.getByTestId('plugin-type-switch')).toHaveAttribute('data-variant', 'home') + }) + + it('keeps tabs clickable and uses only the active background', () => { + render(<HomeCatalogTabs isMarketplacePlatform />) + + const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' }) + + expect(pluginsTab).toHaveAttribute('href', '/plugins') + expect(pluginsTab).toHaveClass('cursor-pointer') + expect(pluginsTab).toHaveClass('bg-state-base-active') + expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect(templatesTab).toHaveAttribute('href', '/templates') + expect(templatesTab).toHaveClass('cursor-pointer') + expect(templatesTab).not.toHaveClass('bg-state-base-active') + }) + + it('leaves both catalog tabs inactive when no page is selected', () => { + render(<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />) + + const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' }) + + expect(pluginsTab).toHaveAttribute('href', '/marketplace') + expect(pluginsTab).not.toHaveAttribute('aria-current') + expect(pluginsTab).not.toHaveClass('bg-state-base-active') + expect(templatesTab).not.toHaveAttribute('aria-current') + expect(templatesTab).not.toHaveClass('bg-state-base-active') + }) + + it('marks Templates as active when rendering the Templates catalog', () => { + render(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />) + + const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' }) + + expect(pluginsTab).not.toHaveAttribute('aria-current') + expect(pluginsTab).not.toHaveClass('bg-state-base-active') + expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect(templatesTab).toHaveAttribute('aria-current', 'page') + expect(templatesTab).toHaveClass('bg-state-base-active') + expect(templatesTab).not.toHaveClass('text-text-accent') + expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + }) + + it('uses request-localized labels and preserves the selected language', () => { + render( + <HomeCatalogTabs + isMarketplacePlatform + labels={{ + plugins: '插件', + templates: '模板', + }} + language="zh-Hans" + />, + ) + + expect(screen.getByRole('link', { name: '插件' })).toHaveAttribute( + 'href', + '/plugins?language=zh-Hans', + ) + expect(screen.getByRole('link', { name: '模板' })).toHaveAttribute( + 'href', + '/templates?language=zh-Hans', + ) + }) + + it('renders a supplied catalog category navigation', () => { + render( + <HomeStickyStateProvider> + <HomeCatalogNavigation + isMarketplacePlatform + catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />} + catalogCategories={<nav aria-label="Template categories">Template categories</nav>} + /> + </HomeStickyStateProvider>, + ) + + expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument() + expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument() + }) + + it('uses a short divider between the leading tag filter and categories', () => { + render( + <HomeStickyStateProvider> + <HomeCatalogNavigation + isMarketplacePlatform + catalogTabs={<HomeCatalogTabs isMarketplacePlatform />} + catalogLeading={<div>Tags</div>} + catalogTrailing={<div>Languages</div>} + catalogCategories={<nav aria-label="Plugin categories">Categories</nav>} + /> + </HomeStickyStateProvider>, + ) + // Categories sit in the flex-1 scroller; the row is one level up. + const row = screen.getByRole('navigation', { name: 'Plugin categories' }).parentElement + ?.parentElement + const divider = row?.children.item(1) + + expect(row?.children.item(0)).toHaveTextContent('Tags') + expect(divider).toHaveAttribute('aria-hidden', 'true') + expect(divider).toHaveClass( + 'mx-1', + 'h-3.5', + 'w-px', + 'shrink-0', + 'bg-divider-regular', + styles.catalogLeadingDivider!, + ) + expect(divider).toBeEmptyDOMElement() + expect(row?.children.item(2)).toHaveTextContent('Categories') + expect(row?.children.item(3)).toHaveTextContent('Languages') + expect(row).not.toHaveTextContent('·') + }) + + it('keeps Dify catalog navigation on the current origin', () => { + renderNavigation(false) + + expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute( + 'href', + '/marketplace', + ) + expect( + screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }), + ).toHaveAttribute('href', '/templates') + }) + + it('keeps both tab copies mounted while exposing only the active copy', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + const containerRect = vi + .spyOn(scrollContainer, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, -100, 100, 100)) + + renderNavigation(true) + + const contentTabs = document.querySelector<HTMLElement>( + '[data-home-catalog-tabs-slot="content"]', + )! + const catalogTabsRegion = contentTabs.parentElement! + const headerTabs = screen.getByTestId('header-catalog-tabs') + const headerTabsSlot = headerTabs.parentElement! + const handoffBoundaryRect = vi + .spyOn(catalogTabsRegion, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, -7, 100, 56)) + + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(contentTabs).not.toHaveAttribute('aria-hidden') + expect(contentTabs).not.toHaveAttribute('inert') + + containerRect.mockReturnValue(new DOMRect(0, 0, 100, 100)) + handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56)) + fireEvent.scroll(scrollContainer) + + expect(headerTabs).toBeInTheDocument() + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + expect(contentTabs).toHaveAttribute('aria-hidden', 'true') + expect(contentTabs).toHaveAttribute('inert') + + scrollContainer.remove() + }) + + it('shows the compact navigation and header tabs after reaching the sticky header', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + + renderNavigation(true) + + const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' }) + const contentTabsSlot = document.querySelector<HTMLElement>( + '[data-home-catalog-tabs-slot="content"]', + )! + const catalogTabsRegion = contentTabsSlot.parentElement! + const headerTabs = screen.getByTestId('header-catalog-tabs') + const headerTabsSlot = headerTabs.parentElement! + vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100)) + const handoffBoundaryRect = vi + .spyOn(catalogTabsRegion, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, -7, 100, 56)) + + fireEvent.scroll(scrollContainer) + expect(headerTabs).toBeInTheDocument() + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + + handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56)) + fireEvent.scroll(scrollContainer) + + expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!) + expect(contentTabsSlot).toHaveClass(styles.catalogTabsPinned!) + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(contentTabsSlot).toHaveAttribute('inert') + + handoffBoundaryRect.mockReturnValue(new DOMRect(0, -7, 100, 56)) + fireEvent.scroll(scrollContainer) + + expect(navigationSection).not.toHaveClass(styles.catalogNavigationPinned!) + expect(headerTabs).toBeInTheDocument() + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + + scrollContainer.remove() + }) + + it('keeps the pinned state when compact styling moves the sticky section', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + + renderNavigation(true) + + const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' }) + const contentTabsSlot = document.querySelector<HTMLElement>( + '[data-home-catalog-tabs-slot="content"]', + )! + const catalogTabsRegion = contentTabsSlot.parentElement! + vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100)) + vi.spyOn(catalogTabsRegion, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, -9, 100, 56), + ) + vi.spyOn(navigationSection, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 49, 100, 60), + ) + + fireEvent.scroll(scrollContainer) + + expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!) + expect(screen.getByTestId('header-catalog-tabs').parentElement).not.toHaveAttribute( + 'aria-hidden', + ) + + scrollContainer.remove() + }) + + it('leaves browser scroll anchoring enabled because the handoff preserves geometry', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + + const { unmount } = renderNavigation(true) + + expect(scrollContainer.style.overflowAnchor).toBe('') + + unmount() + expect(scrollContainer.style.overflowAnchor).toBe('') + + scrollContainer.remove() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx new file mode 100644 index 00000000000..fe6feca9720 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx @@ -0,0 +1,86 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import HomeGuide from '../home-guide' + +const mocks = vi.hoisted(() => ({ + marketplaceUrlPrefix: 'https://marketplace.dify.ai', + useDocLink: vi.fn(() => (path?: string) => `https://docs.dify.ai/console${path || ''}`), +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + i18n: { + language: 'en-US', + }, + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('@/context/i18n', () => ({ + defaultDocBaseUrl: 'https://docs.dify.ai', + useDocLink: mocks.useDocLink, +})) + +vi.mock('@/config', () => ({ + get MARKETPLACE_URL_PREFIX() { + return mocks.marketplaceUrlPrefix + }, +})) + +const openGuideMenu = async (isMarketplacePlatform: boolean) => { + const user = userEvent.setup() + render(<HomeGuide isMarketplacePlatform={isMarketplacePlatform} />) + + expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /requestSubmit/ })) + return within(await screen.findByRole('menu')) +} + +describe('HomeGuide', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai' + }) + + it('opens a four-option dropdown on the standalone Marketplace instead of navigating away', async () => { + const menu = await openGuideMenu(true) + const options = menu.getAllByRole('menuitem') + + expect(options).toHaveLength(4) + expect(options[0]).toHaveAttribute( + 'href', + 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml', + ) + expect(options[1]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin', + ) + expect(options[2]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-overview', + ) + expect(options[3]).toHaveAttribute('href', 'https://creators.dify.ai') + expect(mocks.useDocLink).not.toHaveBeenCalled() + }) + + it('uses Dify deployment-aware documentation links inside the console', async () => { + const menu = await openGuideMenu(false) + const options = menu.getAllByRole('menuitem') + + expect(options).toHaveLength(4) + expect(options[1]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin', + ) + expect(options[2]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/console/develop-plugin/publishing/marketplace-listing/release-overview', + ) + expect(mocks.useDocLink).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx new file mode 100644 index 00000000000..f58cd24cbe5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx @@ -0,0 +1,146 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import HomeHeader from '../home-header' + +const mocks = vi.hoisted(() => ({ + marketplaceUrlPrefix: 'https://marketplace.dify.ai', +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + i18n: { + language: 'en-US', + }, + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('@/context/i18n', () => ({ + defaultDocBaseUrl: 'https://docs.dify.ai', +})) + +vi.mock('@/config', () => ({ + get MARKETPLACE_URL_PREFIX() { + return mocks.marketplaceUrlPrefix + }, +})) + +vi.mock('../home-sticky-state-provider', () => ({ + HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children, +})) + +describe('HomeHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai' + }) + + it('shows Creator Center before the docs dropdown', () => { + render(<HomeHeader isMarketplacePlatform />) + + const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' }) + const guideButton = screen.getByRole('button', { name: /requestSubmit/ }) + + expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/') + expect(creatorCenterLink).toHaveAttribute('target', '_blank') + expect(creatorCenterLink).toHaveAttribute('rel', 'noopener noreferrer') + expect(creatorCenterLink.parentElement?.className).toMatch(/standaloneHeaderActions/) + expect(creatorCenterLink.compareDocumentPosition(guideButton)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) + // Creator Center must be a single interactive element, not a link-wrapped button. + expect(creatorCenterLink.querySelector('button')).toBeNull() + expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument() + }) + + it('links Creator Center to the staging Creators site in staging', () => { + mocks.marketplaceUrlPrefix = 'https://marketplace-staging.dify.dev' + + render(<HomeHeader isMarketplacePlatform />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( + 'href', + 'https://creators-staging.dify.dev/', + ) + }) + + it('links Creator Center to the dev Creators site on marketplace.dify.dev', () => { + mocks.marketplaceUrlPrefix = 'https://marketplace.dify.dev' + + render(<HomeHeader isMarketplacePlatform />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( + 'href', + 'https://creators.dify.dev/', + ) + }) + + it('falls back to the public Creator Center for a custom Marketplace origin', () => { + mocks.marketplaceUrlPrefix = 'http://localhost:3000' + + render(<HomeHeader isMarketplacePlatform />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( + 'href', + 'https://creators.dify.ai/', + ) + }) + + it('renders the Marketplace wordmark without a Marketplace text label', () => { + render(<HomeHeader isMarketplacePlatform />) + + const brandLink = screen.getByRole('link', { name: 'Dify Marketplace' }) + const [lightLogo, darkLogo] = brandLink.querySelectorAll('img') + expect(lightLogo).toHaveAttribute('src', expect.stringContaining('dify-marketplace-logo.svg')) + expect(darkLogo).toHaveAttribute( + 'src', + expect.stringContaining('dify-marketplace-logo-dark.svg'), + ) + expect(lightLogo).toHaveAttribute('width', '141.761') + expect(lightLogo).toHaveAttribute('height', '16.386') + expect(darkLogo).toHaveAttribute('width', '141.761') + expect(darkLogo).toHaveAttribute('height', '16.386') + expect(screen.queryByText('mainNav.marketplace')).not.toBeInTheDocument() + }) + + it('selects neither catalog tab on non-catalog pages', () => { + render( + <HomeHeader + activeTab={null} + catalogLabels={{ + plugins: 'Plugins', + templates: 'Templates', + }} + isMarketplacePlatform + />, + ) + + expect(screen.getByRole('link', { name: 'Plugins' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('link', { name: 'Templates' })).not.toHaveAttribute('aria-current') + }) + + it('shows Templates with only the active background on the Templates catalog', () => { + render( + <HomeHeader + activeTab="templates" + catalogLabels={{ + plugins: '插件', + templates: '模板', + }} + isMarketplacePlatform + language="zh-Hans" + />, + ) + + expect(screen.getByRole('link', { name: '插件' })).not.toHaveAttribute('aria-current') + const templatesTab = screen.getByRole('link', { name: '模板' }) + expect(templatesTab).toHaveAttribute('aria-current', 'page') + expect(templatesTab).toHaveAttribute('href', '/templates?language=zh-Hans') + expect(templatesTab).toHaveClass('bg-state-base-active') + expect(templatesTab).not.toHaveClass('text-text-accent') + expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx new file mode 100644 index 00000000000..14d7ee9d0f6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from '../home-constants' +import HomeHero from '../home-hero' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +describe('HomeHero', () => { + it('renders catalog-specific copy when supplied', () => { + render( + <HomeHero + isMarketplacePlatform + title="Discover templates" + subtitle="Start faster with ready-to-use workflows." + />, + ) + + expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument() + expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument() + expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument() + }) + + it('renders the six decorative hero icons as images instead of iconify masks', () => { + const { container } = render(<HomeHero isMarketplacePlatform />) + + for (const name of [ + 'sparkling-fill', + 'plug-fill', + 'puzzle-fill', + 'brain-2-fill', + 'image-circle-ai-line', + 'voice-ai-fill', + ]) + expect(container.querySelector(`img[src*="${name}"]`)).not.toBeNull() + + expect(container.querySelector('img[src*="google"]')).toBeNull() + expect(container.querySelector('.i-ri-sparkling-fill')).toBeNull() + expect(container.querySelector('.i-custom-public-common-gmail')).toBeNull() + }) + + it('places each decorative icon flush inside a 41px grid cell', () => { + expect(HERO_ICON_SIZE_PX).toBe(HERO_GRID_PITCH_PX - 1) + + const { container } = render(<HomeHero isMarketplacePlatform />) + const icons = [...container.querySelectorAll<HTMLElement>('[aria-hidden] span.absolute')] + expect(icons).toHaveLength(6) + + const plusOffset = /^calc\(50% \+ (-?\d+)px\)$/ + const minusOffset = /^calc\(50% - (\d+)px\)$/ + + for (const icon of icons) { + const plusMatch = plusOffset.exec(icon.style.left) + const minusMatch = minusOffset.exec(icon.style.left) + const left = plusMatch + ? Number(plusMatch[1]) + : minusMatch + ? -Number(minusMatch[1]) + : Number.NaN + const top = Number.parseFloat(icon.style.top) + + expect(left).not.toBeNaN() + expect((left - 1) % HERO_GRID_PITCH_PX === 0).toBe(true) + expect(top % HERO_GRID_PITCH_PX === 0).toBe(true) + } + }) + + it('starts vertical grid lines on the same 50% origin as the icons', () => { + const css = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../home-hero.module.css'), + 'utf8', + ) + + expect(css).toMatch(/background-position:\s*calc\(50% \+ 0\.5px\)/) + expect(css).toMatch(/\.frame\s*\{\s*height:\s*163px/) + expect(css).toMatch(/\.glow\s*\{[\s\S]*?width:\s*555px/) + expect(css).toMatch(/\.glow\s*\{[\s\S]*?height:\s*245px/) + expect(css).toMatch(/\.glow\s*\{[\s\S]*?filter:\s*blur\(30px\)/) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx new file mode 100644 index 00000000000..9a0d6d8d9d8 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx @@ -0,0 +1,211 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import { HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX } from '../home-constants' +import HomeHeader from '../home-header' +import HomeSearch from '../home-search' +import { HomeShell } from '../home-shell' +import styles from '../home-sticky.module.css' + +vi.mock('@/public/marketplace/dify-marketplace-logo-dark.svg', () => ({ + default: { src: '/marketplace/dify-marketplace-logo-dark.svg' }, +})) + +vi.mock('@/public/marketplace/dify-marketplace-logo.svg', () => ({ + default: { src: '/marketplace/dify-marketplace-logo.svg' }, +})) + +vi.mock('../home-catalog-tabs', () => ({ + default: () => null, +})) + +vi.mock('../home-creator-center', () => ({ + default: () => null, +})) + +vi.mock('../home-guide', () => ({ + default: () => null, +})) + +const nextFrame = () => + new Promise<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + +const overlaps = (a: DOMRect, b: DOMRect) => + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top + +const isCenterClickable = (target: Element) => { + const rect = target.getBoundingClientRect() + const node = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) + return Boolean(node && target.contains(node)) +} + +const renderMarketplaceHome = () => + render( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <HomeShell + banners={[]} + header={ + <HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform /> + } + hero={<div aria-hidden style={{ height: 180, flexShrink: 0 }} />} + isMarketplacePlatform + navigation={<div aria-hidden style={{ height: 80, flexShrink: 0 }} />} + page="plugins" + search={ + <HomeSearch enableSearchShortcut={false}> + <input + aria-label="Search plugins or templates" + style={{ display: 'block', height: 36, width: '100%' }} + /> + </HomeSearch> + } + > + <div aria-hidden style={{ height: 640, flexShrink: 0 }} /> + </HomeShell> + </div>, + ) + +describe('Marketplace mobile search layout', () => { + it('pins the mobile search below the header without covering brand or actions', async () => { + await page.viewport(390, 844) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByRole('banner').element() + const brand = screen.getByRole('link', { name: 'Dify Marketplace' }).element() + const signIn = screen.getByRole('button', { name: 'Sign in' }).element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const headerRect = header.getBoundingClientRect() + const searchRect = searchInput.getBoundingClientRect() + + expect(searchRect.top).toBeGreaterThanOrEqual(headerRect.bottom - 1) + expect(searchRect.top).toBeLessThanOrEqual(headerRect.bottom + 2) + expect(overlaps(searchRect, brand.getBoundingClientRect())).toBe(false) + expect(overlaps(searchRect, signIn.getBoundingClientRect())).toBe(false) + expect(isCenterClickable(brand)).toBe(true) + expect(isCenterClickable(signIn)).toBe(true) + expect(isCenterClickable(searchInput)).toBe(true) + }) + + it('keeps bottom padding under the stuck mobile search', async () => { + await page.viewport(390, 844) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const searchRow = document.querySelector(`.${styles.search}`)! + const inputRect = searchInput.getBoundingClientRect() + const rowRect = searchRow.getBoundingClientRect() + + expect(getComputedStyle(searchRow).paddingBottom).toBe( + `${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`, + ) + expect(rowRect.bottom - inputRect.bottom).toBeCloseTo(HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, 0) + }) + + it('keeps the desktop search in the header gap while scrolling', async () => { + await page.viewport(1280, 900) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByRole('banner').element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + expect( + searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top, + ).toBeCloseTo(6, 0) + expect(getComputedStyle(document.querySelector(`.${styles.search}`)!).paddingBottom).toBe('0px') + }) + + it('keeps a search-results search below the header when there is no hero to overlap', async () => { + await page.viewport(1280, 900) + const screen = await render( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <HomeShell + banners={[]} + header={ + <HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform /> + } + hero={null} + isMarketplacePlatform + navigation={null} + page="plugins" + search={ + <HomeSearch enableSearchShortcut={false} overlapHero={false}> + <input + aria-label="Search plugins or templates" + style={{ display: 'block', height: 36, width: '100%' }} + /> + </HomeSearch> + } + > + <div aria-hidden style={{ height: 640, flexShrink: 0 }} /> + </HomeShell> + </div>, + ) + + const header = screen.getByRole('banner').element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + expect(searchInput.getBoundingClientRect().top).toBeGreaterThanOrEqual( + header.getBoundingClientRect().bottom - 1, + ) + expect(searchInput.getBoundingClientRect().width).toBeGreaterThan(300) + }) + + it('does not jump the page when the stuck desktop search is focused or typed into', async () => { + await page.viewport(1280, 900) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByRole('banner').element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const scrollTopBefore = scrollContainer.scrollTop + const inputTopBefore = searchInput.getBoundingClientRect().top + expect(inputTopBefore - header.getBoundingClientRect().top).toBeCloseTo(6, 0) + + const searchLocator = screen.getByRole('textbox', { name: 'Search plugins or templates' }) + await searchLocator.click() + await nextFrame() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + + await searchLocator.fill('g') + await nextFrame() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx new file mode 100644 index 00000000000..b9fb2809f9a --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx @@ -0,0 +1,275 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import HomeTrending from '../home-trending' +import { HomeBannerSlide } from '../home-trending-slides' + +const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({ + id, + style_type: 'blog', + title, + sort, + language: 'en', + content: { + blog_title: title, + subtitle: 'New Agent node support', + description: 'Build agent workflows with the new Agent node.', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, +}) + +const blogBanner = createBlogBanner('blog', 'Dify v1.9 new launch', 0) +const adBanner: PluginBanner = { + id: 'ad', + style_type: 'ad', + title: 'Partner campaign', + sort: 1, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/ad.png', + }, + link: 'https://partner.example.com', + alt_text: 'Partner campaign', + }, +} +const eventBanner: PluginBanner = { + id: 'event', + style_type: 'event', + title: 'Launch event', + sort: 2, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/event.png', + }, + link: 'https://dify.ai/event', + alt_text: 'Launch event', + }, +} +const carouselBanners = [ + createBlogBanner('first', 'First banner', 0), + createBlogBanner('second', 'Second banner', 1), + createBlogBanner('third', 'Third banner', 2), +] + +const visibleReadMore = (slide: Element) => + [...slide.querySelectorAll('[aria-hidden]')].find((el) => { + const text = el.textContent ?? '' + return /Read more|trendingReadMore/.test(text) && el.getBoundingClientRect().height > 0 + }) ?? null + +describe('Marketplace home trending layout', () => { + it('keeps standalone mobile blog banners at the stacked 357px height', async () => { + await page.viewport(600, 900) + await render( + <div data-marketplace-standalone className="w-[560px]"> + <div data-testid="blog-banner"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div> + </div>, + ) + + const blogSlide = document.querySelector<HTMLElement>('[data-testid="blog-banner"] > a')! + + expect(blogSlide.getBoundingClientRect().height).toBe(357) + }) + + it('clamps standalone mobile blog subtitle to one line and description to two', async () => { + await page.viewport(600, 900) + const subtitleText = + 'On September 10, 2026, LangGenius K.K. will host its flagship annual conference in Tokyo.' + const descriptionText = + 'It is a full day dedicated to turning generative AI from isolated pilots into real operations. Registration is open now for the second year of the conference.' + const longTag = 'IF Con Tokyo 2026 Annual Conference Extra Long Label' + const longTitle = 'IF Con Tokyo 2026: Turn “What If” into Production' + const longBlog: PluginBanner = { + id: 'blog-long', + style_type: 'blog', + title: longTag, + sort: 0, + language: 'en', + content: { + blog_title: longTitle, + subtitle: subtitleText, + description: descriptionText, + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + } + const screen = await render( + <div data-marketplace-standalone className="w-[360px]"> + <HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const slide = screen.getByRole('link').element() + const tag = screen.getByText(longTag).element() + const title = screen.getByRole('heading', { name: longTitle }).element() + const subtitle = screen.getByText(subtitleText).element() + const description = screen.getByText(descriptionText).element() + const slideBox = slide.getBoundingClientRect() + const titleBox = title.getBoundingClientRect() + + expect(getComputedStyle(tag).whiteSpace).toBe('nowrap') + expect(getComputedStyle(tag).textOverflow).toBe('ellipsis') + expect(getComputedStyle(title).whiteSpace).toBe('normal') + expect(titleBox.height).toBeGreaterThan(24) + expect(titleBox.left - slideBox.left).toBeCloseTo(20, 0) + expect(slideBox.right - titleBox.right).toBeCloseTo(20, 0) + expect(slideBox.height).toBeGreaterThan(357) + expect(getComputedStyle(subtitle).whiteSpace).toBe('nowrap') + expect(getComputedStyle(subtitle).textOverflow).toBe('ellipsis') + expect(getComputedStyle(description).webkitLineClamp).toBe('2') + expect(description.getBoundingClientRect().height).toBeCloseTo(40, 0) + expect(visibleReadMore(slide)).toBeNull() + }) + + it('clamps the desktop blog tag to one line and lets the title wrap', async () => { + await page.viewport(1200, 900) + const longTag = + "Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents — extra-long green label" + const longTitle = + "Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and AgentsDify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents" + const longBlog: PluginBanner = { + ...createBlogBanner('blog-desktop-long', longTitle, 0), + title: longTag, + } + const screen = await render( + <div className="w-[1100px]"> + <HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const tag = screen.getByText(longTag).element() + const title = screen.getByRole('heading', { name: longTitle }).element() + const tagBox = tag.getBoundingClientRect() + const titleBox = title.getBoundingClientRect() + + expect(getComputedStyle(tag).whiteSpace).toBe('nowrap') + expect(getComputedStyle(tag).textOverflow).toBe('ellipsis') + expect(tagBox.height).toBeLessThanOrEqual(20) + expect(getComputedStyle(title).whiteSpace).toBe('normal') + expect(titleBox.height).toBeGreaterThan(24) + expect(visibleReadMore(screen.getByRole('link').element())).not.toBeNull() + }) + + it('shows the standalone mobile event poster at the 800:721 delivery ratio', async () => { + await page.viewport(600, 900) + const screen = await render( + <div data-marketplace-standalone className="w-[360px]"> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const slide = screen.getByRole('link', { name: 'Launch event' }).element() + const box = slide.getBoundingClientRect() + const artwork = slide.querySelector('img') + + expect(box.height).toBeCloseTo((box.width * 721) / 800, 1) + expect(artwork).not.toBeNull() + expect(getComputedStyle(artwork!).objectFit).toBe('contain') + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + }) + + it('keeps event and ad artwork left-aligned so desktop cropping stays on the right', async () => { + await page.viewport(1000, 900) + const screen = await render( + <div data-marketplace-standalone className="w-[960px]"> + <HomeBannerSlide banner={adBanner} isMarketplacePlatform page="plugins" /> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + for (const name of ['Partner campaign', 'Launch event']) { + const artwork = screen.getByRole('link', { name }).element().querySelector('img') + + expect(artwork).not.toBeNull() + expect(getComputedStyle(artwork!).objectFit).toBe('cover') + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + } + }) + + it('keeps blog artwork at 400px on desktop so shrinking clips the right', async () => { + await page.viewport(1200, 900) + const screen = await render( + <div className="w-[900px]"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const artwork = screen.getByRole('link').element().querySelector('img') + + expect(artwork).not.toBeNull() + expect(artwork!.getBoundingClientRect().width).toBe(400) + expect(getComputedStyle(artwork!).objectFit).toBe('cover') + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + }) + + it('keeps desktop event artwork at least 1200px wide so overflow clips the right', async () => { + await page.viewport(1000, 900) + const screen = await render( + <div data-marketplace-standalone className="w-[900px]"> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const artwork = screen + .getByRole('link', { name: 'Launch event' }) + .element() + .querySelector('img') + + expect(artwork).not.toBeNull() + expect(artwork!.getBoundingClientRect().width).toBeGreaterThanOrEqual(1200) + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + }) + + it('keeps the blog artwork left corners rounded when its image is cropped', async () => { + const screen = await render( + <div className="w-[600px]"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const artwork = screen.getByRole('link').element().querySelector('img') + + expect(artwork).not.toBeNull() + expect(getComputedStyle(artwork!).borderTopLeftRadius).toBe('16px') + expect(getComputedStyle(artwork!).borderBottomLeftRadius).toBe('16px') + }) + + it('moves forwards into the first slide clone before resetting the loop', async () => { + const screen = await render( + <HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />, + ) + + await screen.getByRole('button', { name: 'Third banner' }).click() + await new Promise((resolve) => setTimeout(resolve, 450)) + + const track = document.querySelector<HTMLElement>('[data-carousel-track]')! + const progress = document.querySelector<HTMLElement>('[data-carousel-progress]')! + const progressAnimation = progress.getAnimations()[0] + expect(progressAnimation).toBeDefined() + progressAnimation!.finish() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(track.style.transform).toContain('-300%') + expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument() + + await expect + .poll(() => track.getAttribute('data-carousel-loop-phase'), { timeout: 1000 }) + .toBe('idle') + + expect(screen.getByRole('button', { name: 'First banner' }).element()).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(track.style.transform).toBe('translate3d(0%, 0px, 0px)') + expect(track.querySelector('[data-carousel-loop-clone]')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx new file mode 100644 index 00000000000..ca2f7d81983 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx @@ -0,0 +1,207 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import HomeTrending from '../home-trending' + +const createBanner = (id: string, title: string, sort: number): PluginBanner => ({ + id, + style_type: 'blog', + title, + sort, + language: 'en', + content: { + blog_title: title, + subtitle: `${title} subtitle`, + description: `${title} description`, + link: `https://example.com/${id}`, + link_target_type: 'blog', + }, +}) + +const banners = [ + createBanner('first', 'First banner', 0), + createBanner('second', 'Second banner', 1), + createBanner('third', 'Third banner', 2), +] + +const dispatchTouchPointer = ( + target: Element, + type: 'pointerdown' | 'pointermove' | 'pointerup', + init: Pick<PointerEventInit, 'clientX' | 'clientY' | 'pointerId'>, +) => + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + isPrimary: true, + pointerType: 'touch', + ...init, + }), + ) + +describe('Marketplace home trending mobile swipe', () => { + it('switches in both directions without activating a dragged link or clearing Pause', async () => { + await page.viewport(600, 900) + const screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const firstSlideLocator = screen.getByRole('group', { name: 'First banner' }) + const secondSlideLocator = screen.getByRole('group', { + name: 'Second banner', + includeHidden: true, + }) + const firstSlide = firstSlideLocator.element() + const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')! + + dispatchTouchPointer(firstSlide, 'pointerdown', { + pointerId: 1, + clientX: 480, + clientY: 160, + }) + dispatchTouchPointer(firstSlide, 'pointermove', { + pointerId: 1, + clientX: 300, + clientY: 166, + }) + await expect.element(secondSlideLocator).toBeVisible() + dispatchTouchPointer(firstSlide, 'pointerup', { + pointerId: 1, + clientX: 300, + clientY: 166, + }) + const clickWasNotCanceled = firstLink.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ) + + expect(clickWasNotCanceled).toBe(false) + await expect + .element(screen.getByRole('button', { name: 'Second banner' })) + .toHaveAttribute('aria-current', 'true') + await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).click() + + const secondSlide = secondSlideLocator.element() + dispatchTouchPointer(secondSlide, 'pointerdown', { + pointerId: 2, + clientX: 260, + clientY: 160, + }) + dispatchTouchPointer(secondSlide, 'pointermove', { + pointerId: 2, + clientX: 440, + clientY: 166, + }) + dispatchTouchPointer(secondSlide, 'pointerup', { + pointerId: 2, + clientX: 440, + clientY: 166, + }) + + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + await expect + .element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })) + .toBeInTheDocument() + }) + + it('suppresses the trailing click when a horizontal drag is pulled back before release', async () => { + await page.viewport(600, 900) + const screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + const firstSlide = screen.getByRole('group', { name: 'First banner' }).element() + const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')! + + dispatchTouchPointer(firstSlide, 'pointerdown', { + pointerId: 1, + clientX: 400, + clientY: 160, + }) + dispatchTouchPointer(firstSlide, 'pointermove', { + pointerId: 1, + clientX: 280, + clientY: 164, + }) + dispatchTouchPointer(firstSlide, 'pointermove', { + pointerId: 1, + clientX: 396, + clientY: 162, + }) + dispatchTouchPointer(firstSlide, 'pointerup', { + pointerId: 1, + clientX: 396, + clientY: 162, + }) + + expect( + firstLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })), + ).toBe(false) + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + }) + + it('keeps vertical gestures on the current slide and ignores desktop touch input', async () => { + await page.viewport(600, 900) + let screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + let activeSlide = screen.getByRole('group', { name: 'First banner' }).element() + + dispatchTouchPointer(activeSlide, 'pointerdown', { + pointerId: 1, + clientX: 300, + clientY: 120, + }) + dispatchTouchPointer(activeSlide, 'pointermove', { + pointerId: 1, + clientX: 270, + clientY: 300, + }) + dispatchTouchPointer(activeSlide, 'pointerup', { + pointerId: 1, + clientX: 270, + clientY: 300, + }) + + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + + screen.unmount() + await page.viewport(1000, 900) + screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + activeSlide = screen.getByRole('group', { name: 'First banner' }).element() + + dispatchTouchPointer(activeSlide, 'pointerdown', { + pointerId: 2, + clientX: 480, + clientY: 160, + }) + dispatchTouchPointer(activeSlide, 'pointermove', { + pointerId: 2, + clientX: 260, + clientY: 160, + }) + dispatchTouchPointer(activeSlide, 'pointerup', { + pointerId: 2, + clientX: 260, + clientY: 160, + }) + + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx new file mode 100644 index 00000000000..318482700f1 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -0,0 +1,837 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { act, fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { trackEvent } from '@/app/components/base/amplitude' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import HomeTrending from '../home-trending' + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + rememberMarketplaceSiteReferrer: vi.fn(), + trackMarketplaceSiteEvent: vi.fn(), +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: (namespace: string) => ({ + t: withSelectorKey((key: string) => `${namespace}.${key}`), + }), + } +}) + +vi.mock('@/app/components/plugins/base/badges/partner', () => ({ + default: () => <span data-testid="partner-badge" />, +})) + +vi.mock('@/app/components/plugins/base/badges/verified', () => ({ + default: () => <span data-testid="verified-badge" />, +})) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/config')>()), + MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com', +})) + +const banners: PluginBanner[] = [ + { + id: 'recommend', + style_type: 'recommend', + title: 'Trending', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + heading: 'Popular plugins', + description: 'Chosen from real usage.', + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/dropbox', + display_name: 'Dropbox', + icon_url: '/api/v1/plugins/langgenius/dropbox/icon', + creator: 'langgenius', + badges: ['partner', 'verified'], + link: '/plugins/langgenius/dropbox', + card_position: 0, + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }, + { + item_type: 'plugin', + item_id: 'langgenius/zapier', + display_name: 'Zapier', + link: '/plugins/langgenius/zapier', + card_position: 1, + }, + { + item_type: 'plugin', + item_id: 'langgenius/notion', + display_name: 'Notion', + link: '/plugins/langgenius/notion', + card_position: 2, + }, + { + item_type: 'plugin', + item_id: 'langgenius/slack', + display_name: 'Slack', + link: '/plugins/langgenius/slack', + card_position: 3, + }, + ], + }, + }, + { + id: 'blog', + style_type: 'blog', + title: 'Dify Updates', + sort: 1, + language: 'en', + content: { + blog_title: 'Dify v1.9 new launch', + subtitle: 'New Agent node support', + description: 'Build agent workflows with the new Agent node.', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + { + id: 'event', + style_type: 'event', + title: 'Duck Duck Go', + sort: 2, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/duckduckgo.png', + mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png', + }, + link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo', + alt_text: 'DuckDuckGo plugin', + }, + }, +] + +const mockTrackEvent = vi.mocked(trackEvent) +const mockTrackMarketplaceSiteEvent = vi.mocked(trackMarketplaceSiteEvent) + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('HomeTrending', () => { + it('renders and switches between the three API-backed banner layouts', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + expect(document.querySelector('[data-home-trending-carousel-root]')?.className).toMatch( + /carouselRoot/, + ) + expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument() + const recommendationSlide = screen.getByRole('group', { name: 'Trending' }) + expect( + within(recommendationSlide) + .getAllByRole('link') + .map((link) => link.getAttribute('aria-label')), + ).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack']) + + await user.click(screen.getByRole('button', { name: 'Dify Updates' })) + + expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument() + const blogSlide = screen.getByRole('group', { name: 'Dify Updates' }) + const blogLink = within(blogSlide).getByRole('link', { + name: 'plugin.marketplace.home.trendingReadMoreAbout', + }) + expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog') + expect(within(blogSlide).getAllByRole('link')).toHaveLength(1) + expect( + within(blogLink).getByRole('heading', { name: 'Dify v1.9 new launch' }), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + + expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute( + 'href', + 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo', + ) + }) + + it('marks inactive standalone slides so mobile CSS can collapse mixed banner heights', () => { + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const recommendSlide = screen.getByRole('group', { name: 'Trending' }) + const blogSlide = document.querySelector( + '[aria-roledescription="slide"][aria-label="Dify Updates"]', + ) + const eventSlide = document.querySelector( + '[aria-roledescription="slide"][aria-label="Duck Duck Go"]', + ) + const eventLink = document.querySelector('a[aria-label="DuckDuckGo plugin"]') + + expect(recommendSlide.className).toMatch(/slide/) + expect(recommendSlide.className).not.toMatch(/slideInactive/) + expect(blogSlide?.className).toMatch(/slideInactive/) + expect(eventSlide?.className).toMatch(/slideInactive/) + expect(recommendSlide.firstElementChild?.className).toMatch(/stackedSlide/) + expect(blogSlide?.firstElementChild?.className).toMatch(/stackedSlide/) + expect(eventLink?.className).toMatch(/imageSlide/) + expect(eventLink?.querySelector('source')).toHaveAttribute('media', '(max-width: 879px)') + expect(eventLink?.querySelector('source')?.getAttribute('srcset')).toContain( + 'duckduckgo-mobile.png', + ) + }) + + it('keeps the embedded event image breakpoint at 639px', () => { + render(<HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />) + + expect(document.querySelector('a[aria-label="DuckDuckGo plugin"] source')).toHaveAttribute( + 'media', + '(max-width: 639px)', + ) + }) + + it('falls back to desktop on the mobile source when an event banner has no mobile asset', () => { + const eventWithoutMobile: PluginBanner = { + id: 'event-desktop-only', + style_type: 'event', + title: 'Desktop Event', + sort: 0, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/event-desktop.png', + tablet: '/api/v1/banners/images/banners/event-tablet.png', + }, + link: 'https://dify.ai/event', + alt_text: 'Desktop event', + }, + } + + render(<HomeTrending banners={[eventWithoutMobile]} isMarketplacePlatform page="plugins" />) + + const eventLink = screen.getByRole('link', { name: 'Desktop event' }) + const sources = eventLink.querySelectorAll('source') + + expect(sources[0]).toHaveAttribute('media', '(max-width: 879px)') + expect(sources[0]?.getAttribute('srcset')).toContain('event-desktop.png') + expect(sources[0]?.getAttribute('srcset')).not.toContain('event-tablet.png') + expect(sources[1]).toHaveAttribute('media', '(min-width: 880px) and (max-width: 1023px)') + expect(sources[1]?.getAttribute('srcset')).toContain('event-tablet.png') + expect(eventLink.querySelector('img')?.getAttribute('src')).toContain('event-desktop.png') + }) + + it('switches to the selected slide from the pagination with the keyboard', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' }) + + duckDuckGoButton.focus() + await user.keyboard('{Enter}') + + expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-hidden', + 'false', + ) + }) + + it('loops from the last banner to a visual clone before resetting to the first banner', () => { + const animations: Array<{ + cancel: ReturnType<typeof vi.fn> + onfinish: (() => void) | null + pause: ReturnType<typeof vi.fn> + play: ReturnType<typeof vi.fn> + }> = [] + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => { + const animation = { + cancel: vi.fn(), + onfinish: null, + pause: vi.fn(), + play: vi.fn(), + } + animations.push(animation) + return animation as unknown as Animation + }), + }) + + try { + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + const track = document.querySelector('[data-carousel-track]')! + + act(() => animations.at(-1)?.onfinish?.()) + + expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(track).toHaveStyle({ transform: 'translate3d(-300%, 0, 0)' }) + expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument() + + fireEvent.transitionEnd(track, { propertyName: 'transform' }) + + expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(track).toHaveStyle({ transform: 'translate3d(-0%, 0, 0)', transition: 'none' }) + } finally { + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + } + }) + + it('toggles the carousel between paused and playing states', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const carousel = document.querySelector('[data-home-trending-carousel-root]')! + const liveTrack = carousel.querySelector('[aria-live]')! + expect(liveTrack).toHaveAttribute('aria-live', 'off') + + const pauseButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }) + expect(pauseButton).toHaveClass('bg-state-base-active') + + pauseButton.focus() + await user.keyboard('{Enter}') + + expect(liveTrack).toHaveAttribute('aria-live', 'polite') + + const playButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPlay', + }) + + playButton.focus() + await user.keyboard(' ') + + expect( + screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }), + ).toBeInTheDocument() + }) + + it('starts with autoplay paused when reduced motion is enabled', () => { + const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + expect( + screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPlay', + }), + ).toBeInTheDocument() + + matchMedia.mockRestore() + }) + + it('keeps embedded autoplay paused until every pause reason is cleared', () => { + const pause = vi.fn() + const play = vi.fn() + const cancel = vi.fn() + const progressAnimation = { + cancel, + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const intersectionObservers: { + callback: IntersectionObserverCallback + options?: IntersectionObserverInit + }[] = [] + class MockIntersectionObserver { + disconnect = vi.fn() + observe = vi.fn() + root: Element | Document | null + rootMargin: string + takeRecords = vi.fn(() => []) + thresholds: readonly number[] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + intersectionObservers.push({ callback, options }) + } + } + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + let reducedMotion = false + let reducedMotionListener: (() => void) | undefined + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return reducedMotion + }, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + reducedMotionListener = listener + }, + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + const marketplaceContainer = document.createElement('div') + marketplaceContainer.id = 'marketplace-container' + document.body.appendChild(marketplaceContainer) + + const { unmount } = render( + <HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />, + { + container: marketplaceContainer, + }, + ) + const carouselRoot = marketplaceContainer.querySelector('[data-home-trending-carousel-root]')! + const viewportObserver = intersectionObservers.find( + (observer) => observer.options?.threshold === 0.25, + ) + const setIntersectionRatio = (intersectionRatio: number) => { + act(() => { + viewportObserver?.callback( + [ + { + intersectionRatio, + isIntersecting: intersectionRatio > 0, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + }) + } + + expect(pause).toHaveBeenCalled() + + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(carouselRoot) + setIntersectionRatio(0) + fireEvent.mouseLeave(carouselRoot) + expect(play).toHaveBeenCalledOnce() + + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(2) + + const playsBeforeFocus = play.mock.calls.length + const focusTarget = carouselRoot.querySelector('a')! + fireEvent.focusIn(focusTarget) + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeFocus) + fireEvent.focusOut(focusTarget, { relatedTarget: null }) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeFocus) + + // Navigation controls sit inside the pause boundary, so focusing them + // also stops the rotation. + const playsBeforeControlFocus = play.mock.calls.length + const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) + fireEvent.focusIn(paginationButton) + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeControlFocus) + fireEvent.focusOut(paginationButton, { relatedTarget: null }) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeControlFocus) + + const playsBeforeUserPause = play.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' })) + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeUserPause) + fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeUserPause) + + const playsBeforeVisibilityPause = play.mock.calls.length + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + fireEvent(document, new Event('visibilitychange')) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeVisibilityPause) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + fireEvent(document, new Event('visibilitychange')) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeVisibilityPause) + + const playsBeforeReducedMotion = play.mock.calls.length + reducedMotion = true + reducedMotionListener?.() + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeReducedMotion) + + reducedMotion = false + reducedMotionListener?.() + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeReducedMotion) + + unmount() + marketplaceContainer.remove() + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('resumes autoplay after a pointer click on pagination without waiting for blur', async () => { + const pause = vi.fn() + const play = vi.fn() + const progressAnimation = { + cancel: vi.fn(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const carouselRoot = document.querySelector('[data-home-trending-carousel-root]')! + const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) + + // Pointer activation hovers and focuses the control, which normally + // pauses rotation until mouseleave/focusout. + fireEvent.mouseEnter(carouselRoot) + paginationButton.focus() + fireEvent.focusIn(paginationButton) + + const playsBeforeSelect = play.mock.calls.length + await user.click(paginationButton) + + expect(paginationButton).toHaveAttribute('aria-current', 'true') + expect(document.activeElement).toBe(paginationButton) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeSelect) + + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('keeps autoplay paused when pagination is selected from the keyboard', async () => { + const pause = vi.fn() + const play = vi.fn() + const progressAnimation = { + cancel: vi.fn(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) + paginationButton.focus() + fireEvent.focusIn(paginationButton) + + const playsBeforeSelect = play.mock.calls.length + await user.keyboard('{Enter}') + + expect(paginationButton).toHaveAttribute('aria-current', 'true') + expect(document.activeElement).toBe(paginationButton) + expect(play).toHaveBeenCalledTimes(playsBeforeSelect) + + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('resumes autoplay when Play is activated without moving keyboard focus', async () => { + const pause = vi.fn() + const play = vi.fn() + const progressAnimation = { + cancel: vi.fn(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const toggleButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }) + + // Focusing the toggle adds the implicit focus pause reason, then Enter + // adds the explicit user pause. + toggleButton.focus() + await user.keyboard('{Enter}') + expect(pause).toHaveBeenCalled() + + // Play must resume the rotation even though the button is still focused + // (and would normally keep the focus pause reason active). + const playsBeforePlay = play.mock.calls.length + await user.keyboard('{Enter}') + + expect(play.mock.calls.length).toBeGreaterThan(playsBeforePlay) + expect(document.activeElement).toBe(toggleButton) + expect( + screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }), + ).toBeInTheDocument() + + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('sends embedded cards without a delivery link to the marketplace site', () => { + const bannerWithMixedLinks: PluginBanner = { + id: 'recommend-mixed', + style_type: 'recommend', + title: 'Trending', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/dropbox', + display_name: 'Dropbox', + link: 'https://external.example.com/dropbox', + card_position: 0, + }, + { + // The console has no local /plugin route, so a card without a + // delivery-provided link must open the marketplace detail page. + item_type: 'plugin', + item_id: 'langgenius/notion', + display_name: 'Notion', + link: '', + card_position: 1, + }, + { + item_type: 'template', + item_id: 'tpl-1', + display_name: 'Support Bot', + link: '', + card_position: 2, + }, + ], + }, + } + + render( + <HomeTrending + banners={[bannerWithMixedLinks]} + isMarketplacePlatform={false} + page="plugins" + />, + ) + + expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute( + 'href', + 'https://external.example.com/dropbox', + ) + const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' }) + expect(marketplaceFallbackLink.getAttribute('href')).toMatch( + /^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/, + ) + expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank') + expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute( + 'href', + '/templates?tid=tpl-1', + ) + }) + + it('renders no carousel when the API returns no banners', () => { + render(<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />) + + expect( + screen.queryByRole('region', { + name: 'plugin.marketplace.home.trendingTitle', + }), + ).not.toBeInTheDocument() + }) + + it('tracks recommend card clicks as item clicks without a frame click', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="templates" />) + + await user.click(screen.getByRole('link', { name: 'Dropbox' })) + + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_item_click', { + banner_id: 'recommend', + sort: 0, + page: 'templates', + language: 'en', + style_type: 'recommend', + item_type: 'plugin', + item_id: 'langgenius/dropbox', + card_position: 0, + theme_type: 'hottest', + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }) + expect(mockTrackEvent).not.toHaveBeenCalledWith('marketplace_banner_click', expect.anything()) + }) + + it('tracks whole-slide blog and event links as frame clicks', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + await user.click(screen.getByRole('button', { name: 'Dify Updates' })) + await user.click( + screen.getByRole('link', { name: 'plugin.marketplace.home.trendingReadMoreAbout' }), + ) + + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', { + banner_id: 'blog', + sort: 1, + page: 'plugins', + language: 'en', + style_type: 'blog', + }) + + await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + await user.click(screen.getByRole('link', { name: 'DuckDuckGo plugin' })) + + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', { + banner_id: 'event', + sort: 2, + page: 'plugins', + language: 'en', + style_type: 'event', + }) + }) + + it('does not render banner slides whose CMS link is not http(s) or relative', () => { + const unsafeBlog: PluginBanner = { + id: 'blog-unsafe', + style_type: 'blog', + title: 'Unsafe Updates', + sort: 0, + language: 'en', + content: { + blog_title: 'Unsafe launch', + subtitle: 'Should not be clickable', + description: 'Reject javascript hrefs from CMS payloads.', + link: 'javascript:alert(1)', + link_target_type: 'blog', + }, + } + + render(<HomeTrending banners={[unsafeBlog]} isMarketplacePlatform page="plugins" />) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Unsafe launch' })).not.toBeInTheDocument() + }) + + it('dual-writes banner impressions to Amplitude and marketplace site tracking', () => { + vi.useFakeTimers() + const observers: Array<{ callback: IntersectionObserverCallback }> = [] + class MockIntersectionObserver { + disconnect = vi.fn() + observe = vi.fn() + root: Element | Document | null = null + rootMargin = '0px' + takeRecords = () => [] + thresholds = [0.5] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback) { + observers.push({ callback }) + } + } + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + + try { + const blogBanner = banners[1] + if (!blogBanner) throw new Error('Expected a blog banner fixture') + + render(<HomeTrending banners={[blogBanner]} isMarketplacePlatform page="plugins" />) + + const observer = observers.at(-1) + if (!observer) throw new Error('Expected IntersectionObserver to be registered') + + act(() => { + observer.callback( + [ + { + intersectionRatio: 0.5, + isIntersecting: true, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + }) + act(() => { + vi.advanceTimersByTime(1000) + }) + + const properties = { + banner_id: 'blog', + sort: 1, + page: 'plugins', + language: 'en', + style_type: 'blog', + } + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_impression', properties) + expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( + 'marketplace_banner_impression', + properties, + ) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts new file mode 100644 index 00000000000..2da3ec0be6f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeMarketplaceHref } from '../marketplace-href' + +describe('sanitizeMarketplaceHref', () => { + it('allows http(s) URLs and same-origin relative paths', () => { + expect(sanitizeMarketplaceHref('https://dify.ai/blog')).toBe('https://dify.ai/blog') + expect(sanitizeMarketplaceHref('http://localhost:3000/plugin/a/b')).toBe( + 'http://localhost:3000/plugin/a/b', + ) + expect(sanitizeMarketplaceHref('/plugin/langgenius/dropbox')).toBe('/plugin/langgenius/dropbox') + }) + + it('rejects blank values and non-http schemes', () => { + expect(sanitizeMarketplaceHref('')).toBeNull() + expect(sanitizeMarketplaceHref(' ')).toBeNull() + expect(sanitizeMarketplaceHref('javascript:alert(1)')).toBeNull() + expect(sanitizeMarketplaceHref('data:text/html,bad')).toBeNull() + expect(sanitizeMarketplaceHref('mailto:test@example.com')).toBeNull() + expect(sanitizeMarketplaceHref('//evil.example')).toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx new file mode 100644 index 00000000000..656a2e7f8ba --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MarketplaceLiveSearch from '../marketplace-live-search' + +const { mockReplace } = vi.hoisted(() => ({ + mockReplace: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T) => value, + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), +})) + +describe('MarketplaceLiveSearch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('updates the active tab result route while the user types', async () => { + const user = userEvent.setup() + + render( + <MarketplaceLiveSearch + action="/templates/knowledge" + language="en-US" + placeholder="Search templates" + query="" + />, + ) + + await user.type(screen.getByRole('searchbox'), 'legal') + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', { + scroll: false, + }) + }) + }) + + it('clears the query without leaving the active plugin tab', async () => { + const user = userEvent.setup() + + render( + <MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />, + ) + + await user.clear(screen.getByRole('searchbox')) + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false }) + }) + }) + + it('preserves catalog filter params while the user types', async () => { + const user = userEvent.setup() + + render( + <MarketplaceLiveSearch + action="/templates/knowledge" + placeholder="Search templates" + query="" + preserveParams={{ languages: ['ja'] }} + />, + ) + + await user.type(screen.getByRole('searchbox'), 'legal') + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', { + scroll: false, + }) + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-plugin-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-plugin-search.spec.tsx new file mode 100644 index 00000000000..e7e17b99bf5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-plugin-search.spec.tsx @@ -0,0 +1,22 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vite-plus/test' +import { renderWithNuqs } from '@/test/nuqs-testing' +import MarketplacePluginSearch from '../marketplace-plugin-search' + +describe('MarketplacePluginSearch', () => { + it('updates the catalog query as the user types without opening suggestions', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs(<MarketplacePluginSearch placeholder="Search plugins" />) + + const input = screen.getByRole('searchbox', { name: 'Search plugins' }) + await user.type(input, 'google') + + expect(input).toHaveValue('google') + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google') + }) + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx new file mode 100644 index 00000000000..89103801459 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -0,0 +1,303 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useState } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeSearch from '../home-search' +import { homeCatalogPinnedAtom } from '../home-sticky-state' +import { HomeStickyStateProvider } from '../home-sticky-state-provider' +import { MarketplaceSearchAutocomplete } from '../marketplace-search-autocomplete' + +const { mockTemplateSearch } = vi.hoisted(() => ({ + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T) => value, + } +}) + +vi.mock('react-i18next', async (importOriginal) => { + const original = await importOriginal<typeof import('react-i18next')>() + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return { + ...original, + ...createReactI18nextMock({ + clearSearch: 'Clear search', + loading: 'Loading', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.noPluginFound': 'No integration found', + 'marketplace.viewMore': 'View more', + 'newApp.noTemplateFound': 'No templates found', + }), + } +}) + +vi.mock('@/service/client', async (importOriginal) => { + const original = await importOriginal<typeof import('@/service/client')>() + + return { + ...original, + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => ({ data: { plugins: [], total: 0 } }), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, + } +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, +}) + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +function StickyTemplateSearch() { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search templates" + scope="templates" + value={value} + /> + ) +} + +function PinnedHeaderState() { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( + <header className="sticky top-0 z-50 flex h-12 items-center bg-background-default"> + <span>Dify Marketplace</span> + {isCatalogPinned && ( + <div role="tablist" aria-label="Header catalog tabs"> + Plugins and templates + </div> + )} + </header> + ) +} + +describe('Marketplace search autocomplete layout', () => { + beforeEach(() => { + queryClient.clear() + mockTemplateSearch.mockReset() + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + it('keeps the pinned catalog layout stable while the results popup opens', async () => { + await page.viewport(1280, 720) + + const screen = await render( + <Wrapper> + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + data-testid="marketplace-scroll-container" + style={{ height: 360, width: 1200, overflowY: 'auto' }} + > + <PinnedHeaderState /> + <div style={{ height: 180 }} aria-hidden /> + <HomeSearch enableSearchShortcut={false}> + <StickyTemplateSearch /> + </HomeSearch> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div role="group" aria-label="Template categories" />} + catalogTabs={<div role="tablist" aria-label="Catalog tabs" />} + /> + <main aria-label="Template catalog" style={{ height: 900 }} /> + </div> + </HomeStickyStateProvider> + </Wrapper>, + ) + + const scrollContainer = screen.getByTestId('marketplace-scroll-container').element() + scrollContainer.scrollTop = 300 + scrollContainer.dispatchEvent(new Event('scroll')) + await new Promise(requestAnimationFrame) + + const input = screen.getByRole('combobox', { name: 'Search templates' }) + await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible() + + const catalogNavigation = screen + .getByRole('region', { name: 'common.mainNav.marketplace' }) + .element() + const scrollTopBefore = scrollContainer.scrollTop + const inputTopBefore = input.element().getBoundingClientRect().top + const navigationTopBefore = catalogNavigation.getBoundingClientRect().top + + await input.fill('open') + await expect.element(screen.getByText('No templates found')).toBeVisible() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible() + expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore) + }) + + it('matches the reference grouped panel and compact result spacing', async () => { + await page.viewport(1280, 720) + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + { + id: 'template-2', + template_name: 'Contract Reviewer', + overview: 'Review contracts and identify risks.', + publisher_handle: 'dify', + usage_count: 80, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 2, + }, + }) + + const screen = await render( + <Wrapper> + <div className="w-[420px]"> + <StickyTemplateSearch /> + </div> + </Wrapper>, + ) + + await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal') + await expect.element(screen.getByText('Legal Research Agent')).toBeVisible() + + const list = screen.getByRole('listbox').element() + const panel = list.parentElement! + const templateGroup = screen.getByRole('group', { name: 'Templates' }).element() + const firstItem = screen.getByRole('option', { name: /Legal Research Agent/ }).element() + const lastItem = screen.getByRole('option', { name: /Contract Reviewer/ }).element() + const panelStyle = getComputedStyle(panel) + const listStyle = getComputedStyle(list) + const templateGroupStyle = getComputedStyle(templateGroup) + const firstItemStyle = getComputedStyle(firstItem) + const statusRoots = screen.getByRole('status').all() + const trailingStatus = statusRoots.at(-1)!.element() + + expect(panelStyle.width).toBe('472px') + expect(panelStyle.paddingTop).toBe('0px') + expect(panelStyle.paddingRight).toBe('0px') + expect(panelStyle.paddingBottom).toBe('0px') + expect(panelStyle.paddingLeft).toBe('0px') + expect(panelStyle.borderRadius).toBe('12px') + expect(listStyle.paddingTop).toBe('0px') + expect(templateGroupStyle.paddingTop).toBe('4px') + expect(templateGroupStyle.paddingRight).toBe('4px') + expect(templateGroupStyle.paddingBottom).toBe('4px') + expect(templateGroupStyle.paddingLeft).toBe('4px') + expect(firstItemStyle.paddingTop).toBe('4px') + expect(firstItemStyle.paddingRight).toBe('4px') + expect(firstItemStyle.paddingBottom).toBe('4px') + expect(firstItemStyle.paddingLeft).toBe('12px') + expect(firstItemStyle.borderRadius).toBe('8px') + expect(firstItemStyle.marginLeft).toBe('0px') + expect(firstItemStyle.marginRight).toBe('0px') + expect(trailingStatus.getBoundingClientRect().height).toBe(0) + expect( + panel.getBoundingClientRect().bottom - lastItem.getBoundingClientRect().bottom, + ).toBeCloseTo(5) + }) + + it('keeps result rows fully clickable without a persistent trailing arrow', async () => { + await page.viewport(390, 844) + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + + const screen = await render( + <Wrapper> + <div className="w-full px-4"> + <StickyTemplateSearch /> + </div> + </Wrapper>, + ) + + await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal') + const result = screen.getByRole('option', { name: /Legal Research Agent/ }) + await expect.element(result).toBeVisible() + + const resultElement = result.element() + const resultRect = resultElement.getBoundingClientRect() + const label = screen.getByText('Legal Research Agent').element() + const labelRectBeforeHover = label.getBoundingClientRect() + const trailingVisuals = Array.from( + resultElement.querySelectorAll<HTMLElement>('[aria-hidden="true"]'), + ).filter((element) => { + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.left >= resultRect.right - 40 + }) + + expect(trailingVisuals).toHaveLength(0) + expect(getComputedStyle(resultElement).cursor).toBe('pointer') + + const backgroundBeforeHover = getComputedStyle(resultElement).backgroundColor + await result.hover() + const labelRectAfterHover = label.getBoundingClientRect() + + expect(getComputedStyle(resultElement).backgroundColor).not.toBe(backgroundBeforeHover) + expect(labelRectAfterHover.left).toBeCloseTo(labelRectBeforeHover.left) + expect(labelRectAfterHover.width).toBeCloseTo(labelRectBeforeHover.width) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx new file mode 100644 index 00000000000..78609985b0b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -0,0 +1,580 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} from '../marketplace-search-autocomplete' + +const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ + // Most tests bypass the debounce for simplicity; the debounce-window test + // flips this on to exercise the real 300ms lag. + debounceState: { useRealDebounce: false }, + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T, options?: { wait?: number }) => + debounceState.useRealDebounce ? original.useDebounce(value, options) : value, + } +}) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return createReactI18nextMock({ + clearSearch: 'Clear search', + loading: 'Loading', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.noPluginFound': 'No integration found', + 'marketplace.viewMore': 'View more', + 'newApp.noTemplateFound': 'No templates found', + }) +}) + +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => mockPluginSearch(input), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, +})) + +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +describe('MarketplaceSearchAutocomplete', () => { + beforeEach(() => { + vi.clearAllMocks() + debounceState.useRealDebounce = false + queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, + }) + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + it('shows template suggestions and keeps the route search form contract', async () => { + let resolveTemplateSearch!: (value: unknown) => void + const templateSearchPromise = new Promise((resolve) => { + resolveTemplateSearch = resolve + }) + mockTemplateSearch.mockReturnValue(templateSearchPromise) + const templateSearchResponse = { + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + } + const user = userEvent.setup() + + const { container } = render( + <MarketplaceSearchForm + action="/templates/knowledge" + category="knowledge" + language="en-US" + locale="en-US" + placeholder="Search all templates..." + query="" + scope="templates" + />, + { wrapper: Wrapper }, + ) + + await user.type(screen.getByRole('combobox'), 'legal') + expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument() + expect(screen.getByText(/Loading/)).toBeInTheDocument() + expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull() + resolveTemplateSearch(templateSearchResponse) + + expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument() + expect(screen.getAllByRole('status').length).toBeGreaterThan(0) + expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() + expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument() + expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge') + expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q') + expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text') + expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1) + expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US') + expect(mockPluginSearch).not.toHaveBeenCalled() + }) + + it('shows plugin suggestions while preserving the controlled search owner', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const onValueChange = vi.fn() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={(nextValue) => { + onValueChange(nextValue) + setValue(nextValue) + }} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + + expect(await screen.findByText('Google Search')).toBeInTheDocument() + expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument() + expect(screen.getByRole('listbox').querySelector('img')).toHaveAttribute( + 'src', + `${MARKETPLACE_API_PREFIX}/plugins/langgenius/google-search/icon`, + ) + expect(onValueChange).toHaveBeenLastCalledWith('google') + expect(mockTemplateSearch).not.toHaveBeenCalled() + }) + + it('groups mixed suggestions and submits the complete search from the popup', async () => { + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/" + locale="en-US" + placeholder="Search plugins or templates" + query="" + scope="all" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'search') + + const templateGroup = await screen.findByRole('group', { name: 'Templates' }) + const pluginGroup = screen.getByRole('group', { name: 'Plugins' }) + expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument() + expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'View more' })) + + expect(handleSubmit).toHaveBeenCalledOnce() + }) + + it('submits the route search form when a suggestion is chosen', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/plugins" + locale="en-US" + placeholder="Search plugins" + query="" + scope="plugins" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(handleSubmit).toHaveBeenCalledOnce() + }) + + it('keeps keyboard selection working for the highlighted suggestion', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/plugins" + locale="en-US" + placeholder="Search plugins" + query="" + scope="plugins" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + await user.keyboard('{ArrowDown}{Enter}') + + expect(handleSubmit).toHaveBeenCalledOnce() + }) + + it('hands the selected plugin back to a creator-profile owner without submitting', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const onSuggestionSelect = vi.fn() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <form + onSubmit={(event) => { + handleSubmit(event.nativeEvent) + }} + > + <MarketplaceSearchAutocomplete + locale="en-US" + onSuggestionSelect={onSuggestionSelect} + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + </form> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(onSuggestionSelect).toHaveBeenCalledWith({ + kind: 'plugin', + plugin: expect.objectContaining({ + org: 'langgenius', + name: 'google-search', + }), + }) + expect(handleSubmit).not.toHaveBeenCalled() + expect(screen.getByRole('combobox')).toHaveValue('') + }) + + it('does not offer the previous term suggestions while a new search is pending', async () => { + const googleResponse = { + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + } + mockPluginSearch.mockImplementation((input: { body: { query: string } }) => { + if (input.body.query === 'google') return Promise.resolve(googleResponse) + // Keep the follow-up term pending so stale suggestions would be visible + // if the query still returned placeholder data. + return new Promise(() => {}) + }) + const user = userEvent.setup() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + + await user.type(screen.getByRole('combobox'), ' drive') + + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.getByText(/Loading/)).toBeInTheDocument() + expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull() + }) + + it('does not reopen after dismiss while a request is still pending', async () => { + let resolvePluginSearch!: (value: unknown) => void + mockPluginSearch.mockReturnValue( + new Promise((resolve) => { + resolvePluginSearch = resolve + }), + ) + const user = userEvent.setup() + const pluginResponse = { + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + } + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <> + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + <button type="button">Outside search</button> + </> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + expect(screen.getByText(/Loading/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Outside search' })) + await waitFor(() => { + expect(screen.getByText(/Loading/)).not.toBeVisible() + }) + + resolvePluginSearch(pluginResponse) + + await waitFor(() => { + expect(mockPluginSearch).toHaveBeenCalled() + }) + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + }) + + it('keeps the empty and status roots mounted when nothing matches', async () => { + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + const user = userEvent.setup() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'zzzz') + + expect(await screen.findByText('No integration found')).toBeInTheDocument() + expect(screen.getAllByRole('status').length).toBeGreaterThan(0) + expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() + }) + + it('clears suggestions while the edited value is still debouncing', async () => { + debounceState.useRealDebounce = true + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + // Suggestions only appear once the real 300ms debounce has elapsed. + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + + // For the first 300ms after editing, the debounced term still points at + // the old query; the previous suggestions must already be gone. + await user.type(screen.getByRole('combobox'), ' drive') + + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.getByText(/Loading/)).toBeInTheDocument() + expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx new file mode 100644 index 00000000000..da5a92037dc --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx @@ -0,0 +1,45 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll' + +const nextFrame = () => + new Promise<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + +describe('Sticky search scroll guard', () => { + it('keeps the scroll position when Chromium focuses the in-flow sticky input', async () => { + await page.viewport(1280, 900) + + const screen = await render( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <div style={{ height: 48, flexShrink: 0 }}>Header</div> + <div style={{ height: 180, flexShrink: 0 }}>Hero</div> + <div + data-testid="search-root" + style={{ position: 'sticky', top: 6, height: 36, marginTop: -36 }} + > + <input aria-label="Search plugins or templates" style={{ height: 36, width: '100%' }} /> + </div> + <div style={{ height: 900, flexShrink: 0 }}>Catalog</div> + </div>, + ) + + const container = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const searchRoot = screen.getByTestId('search-root').element() + const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element() + + const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container) + container.scrollTop = 400 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + const scrollTopBefore = container.scrollTop + HTMLInputElement.prototype.focus.call(input) + await nextFrame() + + expect(container.scrollTop).toBe(scrollTopBefore) + stop() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts new file mode 100644 index 00000000000..e32a020f211 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts @@ -0,0 +1,137 @@ +import { act, render } from '@testing-library/react' +import { createElement, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useBannerViewability } from '../use-banner-viewability' + +type ObserverRecord = { + callback: IntersectionObserverCallback + options?: IntersectionObserverInit +} + +let observers: ObserverRecord[] = [] + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '' + readonly thresholds: readonly number[] + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords = () => [] + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + observers.push({ callback, options }) + } +} + +function ViewabilityProbe({ + enabled = true, + onImpression, +}: { + enabled?: boolean + onImpression: () => void +}) { + const targetRef = useRef<HTMLDivElement>(null) + useBannerViewability(targetRef, onImpression, enabled) + return createElement('div', { ref: targetRef, 'data-testid': 'banner-slide' }) +} + +function triggerIntersection(intersectionRatio: number) { + const observer = observers.at(-1) + if (!observer) throw new Error('Expected IntersectionObserver to be registered') + + act(() => { + observer.callback( + [ + { + intersectionRatio, + isIntersecting: intersectionRatio > 0, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + }) +} + +describe('useBannerViewability', () => { + beforeEach(() => { + observers = [] + vi.useFakeTimers() + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('records one impression after the slide stays at least 50% visible for 1000ms', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.5) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onImpression).toHaveBeenCalledOnce() + + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(onImpression).toHaveBeenCalledOnce() + }) + + it('records a second impression after the slide leaves and becomes viewable again', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.8) + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(onImpression).toHaveBeenCalledOnce() + + triggerIntersection(0) + triggerIntersection(0.6) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onImpression).toHaveBeenCalledTimes(2) + }) + + it('does not record an impression when the slide is visible for less than 1s', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.9) + act(() => { + vi.advanceTimersByTime(999) + }) + triggerIntersection(0) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onImpression).not.toHaveBeenCalled() + }) + + it('does not record an impression when the visible ratio stays below 0.5', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.49) + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(onImpression).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/assets/background.webp b/web/app/components/plugins/marketplace/home/assets/background.webp new file mode 100644 index 00000000000..ff09b6466a6 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/background.webp differ diff --git a/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg b/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg new file mode 100644 index 00000000000..c747d0dbf1d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="brain-2-fill"> +<path id="Vector" d="M8.5 2C6.567 2 5 3.567 5 5.5C5 5.68016 5.01364 5.85714 5.03993 6.02997C3.32436 6.25523 2 7.72295 2 9.5C2 10.4793 2.40223 11.3647 3.05051 12C2.40223 12.6353 2 13.5207 2 14.5C2 15.9018 2.82359 17.1104 4.01353 17.6693C4.00457 17.7785 4 17.8888 4 18C4 20.2091 5.79086 22 8 22C9.19469 22 10.2671 21.4762 11 20.6458V3.05051C10.3647 2.40223 9.47934 2 8.5 2ZM13 3.05051V20.6458C13.7329 21.4762 14.8053 22 16 22C18.2091 22 20 20.2091 20 18C20 17.8888 19.9954 17.7785 19.9865 17.6693C21.1764 17.1104 22 15.9018 22 14.5C22 13.5207 21.5978 12.6353 20.9495 12C21.5978 11.3647 22 10.4793 22 9.5C22 7.72295 20.6756 6.25523 18.9601 6.02997C18.9864 5.85714 19 5.68016 19 5.5C19 3.567 17.433 2 15.5 2C14.5207 2 13.6353 2.40223 13 3.05051Z" fill="#0033FF"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png new file mode 100644 index 00000000000..12d192cef36 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png differ diff --git a/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg b/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg new file mode 100644 index 00000000000..d2fa982771e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="image-circle-ai-line"> +<path id="Vector" d="M20.4668 8.69379L20.7134 8.12811C21.1529 7.11947 21.9445 6.31641 22.9323 5.87708L23.6919 5.53922C24.1027 5.35653 24.1027 4.75881 23.6919 4.57612L22.9748 4.25714C21.9616 3.80651 21.1558 2.97373 20.7238 1.93083L20.4706 1.31953C20.2942 0.893489 19.7058 0.893489 19.5293 1.31953L19.2761 1.93083C18.8442 2.97373 18.0384 3.80651 17.0252 4.25714L16.308 4.57612C15.8973 4.75881 15.8973 5.35653 16.308 5.53922L17.0677 5.87708C18.0555 6.31641 18.8471 7.11947 19.2866 8.12811L19.5331 8.69379C19.7136 9.10792 20.2864 9.10792 20.4668 8.69379ZM12 4C7.58172 4 4 7.58172 4 12C4 14.4636 5.11358 16.6671 6.86484 18.1346L14.2925 10.707C14.683 10.3164 15.3162 10.3164 15.7067 10.707L19.5761 14.5764C19.5773 14.5729 19.5785 14.5693 19.5797 14.5658C19.8522 13.7604 20 12.8975 20 12C20 11.6765 19.9809 11.3579 19.9437 11.0452L21.9298 10.8094C21.9762 11.2002 22 11.5975 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C12.8614 2 13.6987 2.10914 14.4983 2.31487L14 4.25179C13.3618 4.0876 12.6919 4 12 4ZM10.813 19.9125C11.2 19.9701 11.5962 19.9998 11.9996 19.9998C14.7613 19.9998 17.1992 18.6003 18.6379 16.4666L14.9996 12.8283L8.58927 19.2386L8.59334 19.2405C9.28476 19.5664 10.0304 19.7961 10.813 19.9125ZM11 10C11 11.1046 10.1046 12 9 12C7.89543 12 7 11.1046 7 10C7 8.89543 7.89543 8 9 8C10.1046 8 11 8.89543 11 10Z" fill="#FF4405"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/plug-fill.svg b/web/app/components/plugins/marketplace/home/assets/plug-fill.svg new file mode 100644 index 00000000000..d6c546294e6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/plug-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="plug-fill"> +<path id="Vector" d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V10H20V14C20 16.2091 18.2091 18 16 18H13ZM16 6H19C19.5523 6 20 6.44772 20 7V9H4V7C4 6.44772 4.44772 6 5 6H8V2H10V6H14V2H16V6ZM12 14.5C12.5523 14.5 13 14.0523 13 13.5C13 12.9477 12.5523 12.5 12 12.5C11.4477 12.5 11 12.9477 11 13.5C11 14.0523 11.4477 14.5 12 14.5Z" fill="#0E9384"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg b/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg new file mode 100644 index 00000000000..f9e75e09c07 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="IconPuzzleFill"> +<path id="Vector" d="M9.5 4V3.5C9.5 2.11929 10.6193 1 12 1C13.3807 1 14.5 2.11929 14.5 3.5V4H20C20.5523 4 21 4.44772 21 5V9C21 9.27614 20.7761 9.5 20.5 9.5C19.1193 9.5 18 10.6193 18 12C18 13.3807 19.1193 14.5 20.5 14.5C20.7761 14.5 21 14.7239 21 15V19C21 19.5523 20.5523 20 20 20H4C3.44772 20 3 19.5523 3 19V5C3 4.44772 3.44772 4 4 4H9.5Z" fill="#0BA5EC"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp b/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp new file mode 100644 index 00000000000..54e94fb7de7 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp differ diff --git a/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg b/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg new file mode 100644 index 00000000000..3fa7e2c52af --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="sparkling-fill"> +<path id="Vector" d="M14 4.4375C15.3462 4.4375 16.4375 3.34619 16.4375 2H17.5625C17.5625 3.34619 18.6538 4.4375 20 4.4375V5.5625C18.6538 5.5625 17.5625 6.65381 17.5625 8H16.4375C16.4375 6.65381 15.3462 5.5625 14 5.5625V4.4375ZM1 11C4.31371 11 7 8.31371 7 5H9C9 8.31371 11.6863 11 15 11V13C11.6863 13 9 15.6863 9 19H7C7 15.6863 4.31371 13 1 13V11ZM17.25 14C17.25 15.7949 15.7949 17.25 14 17.25V18.75C15.7949 18.75 17.25 20.2051 17.25 22H18.75C18.75 20.2051 20.2051 18.75 22 18.75V17.25C20.2051 17.25 18.75 15.7949 18.75 14H17.25Z" fill="#7839EE"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg b/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg new file mode 100644 index 00000000000..2124d153d36 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="voice-ai-fill"> +<path id="Vector" d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM8.5 6H6.5V18H8.5V6ZM4 10H2V14H4V10ZM13 2H11V22H13V2ZM17.5 8H15.5V18H17.5V8ZM22 10H20V14H22V10Z" fill="#0BA5EC"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/banners.spec.ts b/web/app/components/plugins/marketplace/home/banners.spec.ts new file mode 100644 index 00000000000..6989ec912e5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/banners.spec.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { marketplaceClient } from '@/service/client' +import { fetchPluginBanners } from './banners' + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + banners: { + list: vi.fn(), + }, + }, +})) + +const mockedListBanners = vi.mocked(marketplaceClient.banners.list) + +describe('fetchPluginBanners', () => { + beforeEach(() => { + mockedListBanners.mockReset() + }) + + it('normalizes every public banner style in API sort order', async () => { + mockedListBanners.mockResolvedValue({ + code: 0, + msg: 'success', + data: { + banners: [ + { + id: 'event', + style_type: 'event', + title: 'Dify Event', + sort: 3, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/event.png', + mobile: '/api/v1/banners/images/banners/event-mobile.png', + }, + link: 'https://dify.ai/events', + alt_text: 'Dify Event', + activity_id: 'event-1', + }, + }, + { + id: 'recommend', + style_type: 'recommend', + title: 'Trending Now', + sort: 1, + language: 'en', + content: { + theme_type: 'hottest', + heading: 'Popular plugins', + description: 'Chosen from real usage.', + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/fourth', + display_name: 'Fourth', + link: '/plugins/langgenius/fourth', + card_position: 3, + }, + { + item_type: 'plugin', + item_id: 'langgenius/first', + display_name: 'First', + icon_url: '/api/v1/plugins/langgenius/first/icon', + creator: 'langgenius', + badges: ['verified', 'partner', 'unknown'], + link: '/plugins/langgenius/first', + card_position: 0, + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }, + { + item_type: 'plugin', + item_id: 'langgenius/third', + display_name: 'Third', + link: '/plugins/langgenius/third', + card_position: 2, + }, + { + item_type: 'plugin', + item_id: 'langgenius/second', + display_name: 'Second', + link: '/plugins/langgenius/second', + card_position: 1, + }, + ], + }, + }, + { + id: 'ad', + style_type: 'ad', + title: 'Partner campaign', + sort: 4, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/ad.webp', + }, + link: 'https://example.com', + partner_id: 'partner-1', + campaign_id: 'campaign-1', + }, + }, + { + id: 'blog', + style_type: 'blog', + title: 'Dify Updates', + sort: 2, + language: 'en', + content: { + blog_title: 'Dify v1.9 new launch', + subtitle: 'New Agent node support', + description: 'Build agent workflows with the new Agent node.', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + { + id: 'unsupported', + style_type: 'popup', + title: 'Unsupported', + sort: 0, + language: 'en', + content: {}, + }, + ], + }, + }) + + const banners = await fetchPluginBanners('en-US') + + expect(mockedListBanners).toHaveBeenCalledWith({ + query: { + page: 'plugins', + language: 'en-US', + }, + }) + expect(banners.map((banner) => banner.id)).toEqual(['recommend', 'blog', 'event', 'ad']) + + const recommend = banners[0] + expect(recommend?.style_type).toBe('recommend') + if (recommend?.style_type === 'recommend') { + expect(recommend.content.cards.map((card) => card.display_name)).toEqual([ + 'First', + 'Second', + 'Third', + 'Fourth', + ]) + expect(recommend.content.cards[0]).toMatchObject({ + creator: 'langgenius', + badges: ['verified', 'partner'], + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }) + } + + const event = banners[2] + expect(event?.style_type).toBe('event') + if (event?.style_type === 'event') { + expect(event.content.images).toEqual({ + desktop: '/api/v1/banners/images/banners/event.png', + mobile: '/api/v1/banners/images/banners/event-mobile.png', + }) + } + }) + + it('drops malformed banners and returns no placeholders for an empty response', async () => { + mockedListBanners + .mockResolvedValueOnce({ + data: { + banners: [ + { + id: 'empty-recommend', + style_type: 'recommend', + title: 'Empty', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + cards: [], + }, + }, + { + id: 'event-without-desktop', + style_type: 'event', + title: 'Broken', + sort: 1, + language: 'en', + content: { + images: { + mobile: '/api/v1/banners/images/banners/mobile.png', + }, + link: 'https://example.com', + }, + }, + ], + }, + }) + .mockResolvedValueOnce('') + + await expect(fetchPluginBanners('en-US')).resolves.toEqual([]) + await expect(fetchPluginBanners('en-US')).resolves.toEqual([]) + }) + + it('requests templates banners when fetching for the templates page', async () => { + mockedListBanners.mockResolvedValue({ + data: { + banners: [], + }, + }) + + await expect(fetchPluginBanners('en-US', 'templates')).resolves.toEqual([]) + expect(mockedListBanners).toHaveBeenCalledWith({ + query: { + page: 'templates', + language: 'en-US', + }, + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts new file mode 100644 index 00000000000..1b1f24cc985 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/banners.ts @@ -0,0 +1,154 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { z } from 'zod' +import { marketplaceClient } from '@/service/client' + +// The banner types live in @dify/contracts/marketplace so the standalone +// marketplace and the embedded console share one definition; this module owns +// the runtime normalization of the untyped delivery payload. +const MAX_CARDS_PER_PAGE = 4 + +// Mirrors the previous hand-rolled parsing: an optional field of the wrong +// type is dropped instead of rejecting the whole banner. +const lenientOptionalString = z.string().optional().catch(undefined) +// Same, but an empty string also collapses to undefined (responsive image +// variants are only useful when they actually point somewhere). +const lenientNonEmptyString = z.string().min(1).optional().catch(undefined) + +const bannerBaseShape = { + id: z.string().min(1), + title: z.string().min(1), + sort: z.number(), + language: z.string().min(1), +} + +const recommendCardSchema = z.object({ + item_type: z.enum(['plugin', 'template']), + item_id: z.string().min(1), + display_name: z.string().min(1), + icon_url: lenientOptionalString, + icon: lenientOptionalString, + icon_background: lenientOptionalString, + creator: lenientOptionalString, + badges: z + .unknown() + .transform((value) => + Array.isArray(value) + ? value.filter( + (badge): badge is 'partner' | 'verified' => badge === 'partner' || badge === 'verified', + ) + : undefined, + ) + // The trailing optional keeps the key optional in the inferred type and + // lets a missing field bypass the transform pipeline. + .optional(), + link: z.string().catch(''), + card_position: z.number().catch(0), + auto_batch_id: z.union([z.string(), z.null()]).optional().catch(undefined), +}) + +const recommendContentSchema = z.object({ + theme_type: z.enum(['newest', 'hottest', 'partner']), + heading: lenientOptionalString, + subheadings: z + .unknown() + .transform((value) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined, + ) + .optional(), + description: lenientOptionalString, + cards: z + .array(recommendCardSchema.nullable().catch(null)) + .catch([]) + .transform((cards) => + cards + .flatMap((card) => (card === null ? [] : [card])) + .sort((a, b) => a.card_position - b.card_position) + .slice(0, MAX_CARDS_PER_PAGE), + ) + // A recommendation banner with no renderable card has nothing to show. + .refine((cards) => cards.length > 0), +}) + +const blogContentSchema = z.object({ + blog_title: z.string().min(1), + subtitle: lenientOptionalString, + description: lenientOptionalString, + link: z.string().min(1), + link_target_type: z.enum(['blog', 'github']), +}) + +const imageContentShape = { + images: z.object({ + desktop: z.string().min(1), + tablet: lenientNonEmptyString, + mobile: lenientNonEmptyString, + }), + link: z.string().min(1), + alt_text: lenientOptionalString, + activity_id: lenientOptionalString, +} + +const pluginBannerSchema = z.discriminatedUnion('style_type', [ + z.object({ + ...bannerBaseShape, + style_type: z.literal('recommend'), + content: recommendContentSchema, + }), + z.object({ + ...bannerBaseShape, + style_type: z.literal('blog'), + content: blogContentSchema, + }), + z.object({ + ...bannerBaseShape, + style_type: z.literal('event'), + content: z.object(imageContentShape), + }), + z.object({ + ...bannerBaseShape, + style_type: z.literal('ad'), + content: z.object({ + ...imageContentShape, + partner_id: lenientOptionalString, + campaign_id: lenientOptionalString, + }), + }), +]) + +const bannersResponseSchema = z.object({ + data: z.object({ + banners: z.array(z.unknown()), + }), +}) + +const normalizePluginBanners = (response: unknown): PluginBanner[] => { + const parsedResponse = bannersResponseSchema.safeParse(response) + if (!parsedResponse.success) return [] + + return parsedResponse.data.data.banners + .flatMap((banner): PluginBanner[] => { + // Malformed banners are dropped individually so one bad delivery entry + // does not blank the whole trending section. + const parsedBanner = pluginBannerSchema.safeParse(banner) + return parsedBanner.success ? [parsedBanner.data] : [] + }) + .sort((a, b) => a.sort - b.sort) +} + +export type MarketplaceBannerPage = 'plugins' | 'templates' + +export const fetchPluginBanners = async ( + language: string, + page: MarketplaceBannerPage = 'plugins', +): Promise<PluginBanner[]> => { + const response = await marketplaceClient.banners.list({ + query: { + page, + language, + }, + }) + + return normalizePluginBanners(response) +} diff --git a/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx b/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx new file mode 100644 index 00000000000..eae77708b2e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx @@ -0,0 +1,177 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' +import { cn } from '@langgenius/dify-ui/cn' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' +import { useFilterTemplateLanguages } from '../atoms' +import { LANGUAGE_OPTIONS } from '../templates/template-language' + +export default function CatalogLanguagesFilter() { + const { t } = useTranslation() + const [languages, setLanguages] = useFilterTemplateLanguages() + const [open, setOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const triggerRef = useRef<HTMLButtonElement>(null) + const shouldRestoreFocusRef = useRef(false) + const selectedOptions = LANGUAGE_OPTIONS.filter((option) => languages.includes(option.value)) + const selectedNativeLabels = selectedOptions.map((option) => option.nativeLabel) + const selectedCount = selectedOptions.length + const triggerLabel = selectedNativeLabels.length + ? selectedNativeLabels.join(', ') + : t(($) => $['marketplace.languages'], { ns: 'plugin' }) + const searchQuery = searchText.toLowerCase() + const filteredOptions = LANGUAGE_OPTIONS.filter( + (option) => + option.label.toLowerCase().includes(searchQuery) || + option.nativeLabel.toLowerCase().includes(searchQuery), + ) + + useEffect(() => { + if (selectedCount || !shouldRestoreFocusRef.current) return + + shouldRestoreFocusRef.current = false + triggerRef.current?.focus() + }, [selectedCount]) + + const handleLanguagesChange = (next: string[]) => { + const addedLanguage = next.find((language) => !languages.includes(language)) + const removedLanguage = languages.find((language) => !next.includes(language)) + markMarketplaceSiteFilter({ + filter_type: 'language', + selection_mode: 'multi', + filter_value: addedLanguage ?? removedLanguage ?? next.at(-1) ?? '', + selected_values: next, + }) + // Server-rendered template results read `languages` from the URL, so this + // update must notify the App Router instead of only rewriting history. + setLanguages(next.length ? next : null, { shallow: false }) + } + + return ( + <Popover open={open} onOpenChange={setOpen}> + <div className="relative inline-flex h-8 shrink-0 items-center"> + <PopoverTrigger + render={ + <Button + ref={triggerRef} + variant="ghost" + size="medium" + aria-label={triggerLabel} + className={cn( + 'h-8 justify-start px-2 py-1 text-text-tertiary focus-visible:ring-inset', + !!selectedCount && + 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs shadow-shadow-shadow-3', + !selectedCount && 'data-popup-open:bg-state-base-hover', + )} + > + <span className="py-0.5"> + <span + aria-hidden + className={cn( + 'i-ri-global-line block size-4', + !!selectedCount && 'text-text-secondary', + )} + /> + </span> + <span className="flex items-center gap-x-1 py-1 system-sm-medium"> + {!selectedCount && ( + <span>{t(($) => $['marketplace.languages'], { ns: 'plugin' })}</span> + )} + {!!selectedCount && ( + <span className="text-text-secondary"> + {selectedNativeLabels.slice(0, 2).join(',')} + </span> + )} + {selectedCount > 2 && ( + <span className="system-xs-medium text-text-tertiary">+{selectedCount - 2}</span> + )} + </span> + {!selectedCount && ( + <span className="py-0.5"> + <span + aria-hidden + className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" + /> + </span> + )} + </Button> + } + /> + {!!selectedCount && ( + <IconButton + variant="ghost" + size="md" + aria-label={t(($) => $.clearSearch, { + ns: 'plugin', + label: triggerLabel, + })} + className="absolute right-1 focus-visible:ring-inset" + onClick={() => { + shouldRestoreFocusRef.current = true + handleLanguagesChange([]) + }} + > + <span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" /> + </IconButton> + )} + </div> + <PopoverContent + placement="bottom-end" + sideOffset={4} + alignOffset={-6} + className="border-none bg-transparent shadow-none" + > + <div className="w-60 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> + <div className="p-2 pb-1"> + <InputGroup> + <InputGroupInput + type="search" + name="language-query" + autoComplete="off" + enterKeyHint="search" + aria-label={t(($) => $['marketplace.searchFilterLanguage'], { ns: 'plugin' }) || ''} + className="[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" + value={searchText} + onValueChange={setSearchText} + placeholder={ + t(($) => $['marketplace.searchFilterLanguage'], { ns: 'plugin' }) || '' + } + /> + <InputGroupAddon className="ps-1.75 pe-0.75"> + <span + aria-hidden + className="i-ri-search-line size-4 text-components-input-text-placeholder" + /> + </InputGroupAddon> + </InputGroup> + </div> + <CheckboxGroup + aria-label={t(($) => $['marketplace.languages'], { ns: 'plugin' })} + value={languages} + onValueChange={handleLanguagesChange} + className="max-h-112 overflow-y-auto p-1" + > + {filteredOptions.map((option) => ( + <label + key={option.value} + className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" + > + <Checkbox className="mr-1" value={option.value} /> + <div className="px-1 system-sm-medium text-text-secondary"> + {option.nativeLabel} + </div> + </label> + ))} + </CheckboxGroup> + </div> + </PopoverContent> + </Popover> + ) +} diff --git a/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx b/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx new file mode 100644 index 00000000000..d0ee51665e3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useFilterPluginTags } from '../atoms' +import TagsFilter from '../search-box/tags-filter' + +export default function CatalogTagsFilter() { + const [tags, setTags] = useFilterPluginTags() + return ( + <TagsFilter + tags={tags} + onTagsChange={(next) => setTags(next.length ? next : null)} + usedInMarketplace + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts b/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts new file mode 100644 index 00000000000..7bc39fb631f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts @@ -0,0 +1,22 @@ +export const MARKETPLACE_MOBILE_BANNER_MEDIA = '(max-width: 879px)' +export const EMBEDDED_MOBILE_BANNER_MEDIA = '(max-width: 639px)' + +export function marketplaceTabletBannerMedia(isMarketplacePlatform: boolean) { + return isMarketplacePlatform + ? '(min-width: 880px) and (max-width: 1023px)' + : '(min-width: 640px) and (max-width: 1023px)' +} + +export function resolveEventAdBannerImageSrcs(images: { + desktop: string + tablet?: string + mobile?: string +}) { + return { + desktop: images.desktop, + // Phones always get a source: the mobile asset when present, otherwise desktop. + // That keeps tablet from winning at mobile widths. + mobile: images.mobile || images.desktop, + tablet: images.tablet || undefined, + } +} diff --git a/web/app/components/plugins/marketplace/home/home-catalog-focus.ts b/web/app/components/plugins/marketplace/home/home-catalog-focus.ts new file mode 100644 index 00000000000..47614c6ce8b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-catalog-focus.ts @@ -0,0 +1,21 @@ +export type HomeCatalogTabSlot = 'content' | 'header' + +const getCatalogTabSlot = (slot: HomeCatalogTabSlot) => + document.querySelector<HTMLElement>(`[data-home-catalog-tabs-slot="${slot}"]`) + +export const getFocusedCatalogTabHref = (slot: HomeCatalogTabSlot) => { + const slotElement = getCatalogTabSlot(slot) + const activeElement = document.activeElement + if (!slotElement || !activeElement || !slotElement.contains(activeElement)) return null + + return activeElement.closest<HTMLAnchorElement>('a[href]')?.getAttribute('href') ?? null +} + +export const focusCatalogTab = (slot: HomeCatalogTabSlot, href: string) => { + const slotElement = getCatalogTabSlot(slot) + const matchingLink = Array.from( + slotElement?.querySelectorAll<HTMLAnchorElement>('a[href]') ?? [], + ).find((link) => link.getAttribute('href') === href) + + matchingLink?.focus({ preventScroll: true }) +} diff --git a/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx new file mode 100644 index 00000000000..46aa76f66fe --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx @@ -0,0 +1,135 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import PluginTypeSwitch from '../plugin-type-switch' +import { focusCatalogTab, getFocusedCatalogTabHref } from './home-catalog-focus' +import { HOME_HEADER_HEIGHT_PX } from './home-constants' +import { homeCatalogPinnedAtom } from './home-sticky-state' +import styles from './home-sticky.module.css' + +type HomeCatalogNavigationProps = { + catalogCategories?: ReactNode + catalogLeading?: ReactNode + catalogTabs: ReactNode + catalogTrailing?: ReactNode + isMarketplacePlatform: boolean +} + +function HomeCatalogNavigation({ + catalogCategories, + catalogLeading, + catalogTabs, + catalogTrailing, + isMarketplacePlatform, +}: HomeCatalogNavigationProps) { + const { t } = useTranslation() + const isPinned = useAtomValue(homeCatalogPinnedAtom) + const setIsPinned = useSetAtom(homeCatalogPinnedAtom) + const isPinnedRef = useRef(isPinned) + const pendingFocusedTabHrefRef = useRef<string | null>(null) + const catalogTabsRegionRef = useRef<HTMLDivElement>(null) + + useLayoutEffect(() => { + isPinnedRef.current = isPinned + const focusedTabHref = pendingFocusedTabHrefRef.current + if (!focusedTabHref) return + + pendingFocusedTabHrefRef.current = null + focusCatalogTab(isPinned ? 'header' : 'content', focusedTabHref) + }, [isPinned]) + + useEffect(() => { + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID) + if (!scrollContainer) return + const desktopHeaderSlotQuery = + isMarketplacePlatform && typeof window.matchMedia === 'function' + ? window.matchMedia('(min-width: 880px)') + : null + + const updatePinnedState = () => { + const catalogTabsRegion = catalogTabsRegionRef.current + if (!catalogTabsRegion) return + + const containerTop = scrollContainer.getBoundingClientRect().top + const catalogTabsRegionBottom = catalogTabsRegion.getBoundingClientRect().bottom + const canUseHeaderSlot = + !isMarketplacePlatform || !desktopHeaderSlotQuery || desktopHeaderSlotQuery.matches + const nextIsPinned = + canUseHeaderSlot && catalogTabsRegionBottom <= containerTop + HOME_HEADER_HEIGHT_PX + if (nextIsPinned === isPinnedRef.current) return + + pendingFocusedTabHrefRef.current = getFocusedCatalogTabHref( + nextIsPinned ? 'content' : 'header', + ) + isPinnedRef.current = nextIsPinned + setIsPinned(nextIsPinned) + } + + updatePinnedState() + scrollContainer.addEventListener('scroll', updatePinnedState, { passive: true }) + desktopHeaderSlotQuery?.addEventListener('change', updatePinnedState) + window.addEventListener('resize', updatePinnedState) + + return () => { + scrollContainer.removeEventListener('scroll', updatePinnedState) + desktopHeaderSlotQuery?.removeEventListener('change', updatePinnedState) + window.removeEventListener('resize', updatePinnedState) + } + }, [isMarketplacePlatform, setIsPinned]) + + return ( + <div className={styles.catalogNavigationGroup}> + <div + ref={catalogTabsRegionRef} + className={cn('w-full shrink-0 bg-background-default', styles.catalogTabsRegion)} + > + <div + aria-hidden={isPinned ? true : undefined} + className={cn(styles.catalogTabs, isPinned && styles.catalogTabsPinned)} + data-home-catalog-tabs-slot="content" + inert={isPinned ? true : undefined} + > + {catalogTabs} + </div> + </div> + <section + aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })} + className={cn( + 'w-full shrink-0 bg-background-default', + styles.catalogNavigation, + isPinned && styles.catalogNavigationPinned, + )} + // Pins directly below the header, so the offset is the header height. + style={{ top: HOME_HEADER_HEIGHT_PX }} + > + <div className="w-full"> + <div className="flex w-full items-center gap-2"> + {catalogLeading ? ( + <> + <div className={cn('shrink-0', styles.catalogLeading)}>{catalogLeading}</div> + <div + aria-hidden + className={cn( + 'mx-1 h-3.5 w-px shrink-0 bg-divider-regular', + styles.catalogLeadingDivider, + )} + /> + </> + ) : null} + <div className="min-w-0 flex-1 scrollbar-none overflow-x-auto"> + {catalogCategories ?? <PluginTypeSwitch className={undefined} variant="home" />} + </div> + {catalogTrailing ? <div className="shrink-0">{catalogTrailing}</div> : null} + </div> + </div> + </section> + </div> + ) +} + +export default HomeCatalogNavigation diff --git a/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx new file mode 100644 index 00000000000..30dcbc41106 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx @@ -0,0 +1,77 @@ +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import Link from '@/next/link' + +export type HomeCatalogTab = 'plugins' | 'templates' +export type HomeCatalogTabLabels = Record<HomeCatalogTab, string> + +type HomeCatalogTabsProps = { + activeTab?: HomeCatalogTab | null + className?: string + isMarketplacePlatform: boolean + labels?: HomeCatalogTabLabels + language?: string +} + +const HomeCatalogTabs = ({ + activeTab = 'plugins', + className, + isMarketplacePlatform, + labels, + language, +}: HomeCatalogTabsProps) => { + const { t } = useTranslation() + const catalogParams = language ? { language } : undefined + const getRelativeCatalogHref = (path: string) => { + const searchParams = new URLSearchParams(catalogParams) + const queryString = searchParams.toString() + return queryString ? `${path}?${queryString}` : path + } + const pluginsHref = isMarketplacePlatform + ? getRelativeCatalogHref('/plugins') + : getRelativeCatalogHref('/marketplace') + const templatesHref = getRelativeCatalogHref('/templates') + const isPluginsActive = activeTab === 'plugins' + const isTemplatesActive = activeTab === 'templates' + const pluginsLabel = labels?.plugins ?? t(($) => $['marketplace.home.plugins'], { ns: 'plugin' }) + const templatesLabel = + labels?.templates ?? t(($) => $['marketplace.home.templates'], { ns: 'plugin' }) + + return ( + <nav + aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })} + className={cn('flex h-8 items-center gap-1', className)} + > + <Link + href={pluginsHref} + aria-label={pluginsLabel} + aria-current={isPluginsActive ? 'page' : undefined} + className={cn( + 'flex h-8 cursor-pointer items-start rounded-lg px-[9px] pt-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isPluginsActive ? 'body-sm-medium' : 'body-sm-regular', + isPluginsActive + ? 'bg-state-base-active text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover', + )} + > + {pluginsLabel} + </Link> + <Link + href={templatesHref} + aria-label={templatesLabel} + aria-current={isTemplatesActive ? 'page' : undefined} + className={cn( + 'relative flex h-8 cursor-pointer items-center rounded-[10px] p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isTemplatesActive ? 'body-sm-medium' : 'body-sm-regular', + isTemplatesActive + ? 'bg-state-base-active text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover', + )} + > + {templatesLabel} + </Link> + </nav> + ) +} + +export default HomeCatalogTabs diff --git a/web/app/components/plugins/marketplace/home/home-constants.ts b/web/app/components/plugins/marketplace/home/home-constants.ts new file mode 100644 index 00000000000..ed10c674f37 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-constants.ts @@ -0,0 +1,15 @@ +/** + * Height of the marketplace home header in pixels. Sticky home chrome reads + * this so the header, search, and catalog offsets cannot drift apart. + */ +export const HOME_HEADER_HEIGHT_PX = 48 + +/** Height of the home search row. HomeSearch and the mobile catalog offset both read this. */ +export const HOME_SEARCH_HEIGHT_PX = 36 + +/** Extra sticky-chrome gap under the mobile search row (ECO-475). */ +export const HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX = 16 + +/** 40px icon tiles + 1px divider-subtle lines in the marketplace home hero. */ +export const HERO_GRID_PITCH_PX = 41 +export const HERO_ICON_SIZE_PX = 40 diff --git a/web/app/components/plugins/marketplace/home/home-creator-center.tsx b/web/app/components/plugins/marketplace/home/home-creator-center.tsx new file mode 100644 index 00000000000..954ca24453b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-creator-center.tsx @@ -0,0 +1,38 @@ +'use client' + +import { buttonVariants } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import { MARKETPLACE_URL_PREFIX } from '@/config' +import Link from '@/next/link' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import { useCreatorCenterUrl } from '../creator-center-url' + +export default function HomeCreatorCenter() { + const { t } = useTranslation('plugin') + const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX) + const label = t(($) => $['marketplace.home.creatorCenter']) + + return ( + <Link + href={creatorCenterUrl} + target="_blank" + rel="noopener noreferrer" + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'creator_center', + }) + }} + // The visible text is hidden below the lg breakpoint, so the link needs + // an explicit accessible name to avoid becoming an icon-only mystery. + aria-label={label} + className={cn( + buttonVariants({ variant: 'ghost' }), + 'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary [html[data-theme=dark]_&]:text-text-primary [html[data-theme=dark]_&]:hover:text-text-primary', + )} + > + <span aria-hidden className="i-ri-user-star-line size-4" /> + <span className="hidden system-sm-medium lg:inline">{label}</span> + </Link> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-guide.tsx b/web/app/components/plugins/marketplace/home/home-guide.tsx new file mode 100644 index 00000000000..bceac49c2df --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-guide.tsx @@ -0,0 +1,25 @@ +'use client' + +import type { DocPathWithoutLang } from '@/types/doc-paths' +import { useTranslation } from '#i18n' +import { + SubmitRequestDropdown, + SubmitRequestDropdownMenu, +} from '@/app/components/plugins/plugin-page/nav-operations' +import { defaultDocBaseUrl } from '@/context/i18n' +import { getDocLanguage } from '@/i18n-config/language' + +function MarketplaceGuide() { + const { i18n } = useTranslation() + const docLanguage = getDocLanguage(i18n.language) + const docLink = (path: DocPathWithoutLang) => `${defaultDocBaseUrl}/${docLanguage}${path}` + + return <SubmitRequestDropdownMenu dividerAfterFirst docLink={docLink} /> +} + +export default function HomeGuide({ isMarketplacePlatform }: { isMarketplacePlatform: boolean }) { + // Standalone Marketplace cannot call useDocLink(): it reads the console-only + // systemFeatures suspense query and crashes SSR. The dropdown paths have no + // product-specific variants, so composing the URL from the locale matches. + return isMarketplacePlatform ? <MarketplaceGuide /> : <SubmitRequestDropdown dividerAfterFirst /> +} diff --git a/web/app/components/plugins/marketplace/home/home-header.tsx b/web/app/components/plugins/marketplace/home/home-header.tsx new file mode 100644 index 00000000000..f608133b69c --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-header.tsx @@ -0,0 +1,93 @@ +import type { HomeCatalogTab, HomeCatalogTabLabels } from './home-catalog-tabs' +import { cn } from '@langgenius/dify-ui/cn' +import Link from '@/next/link' +import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg' +import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg' +import HomeCatalogTabs from './home-catalog-tabs' +import { HOME_HEADER_HEIGHT_PX } from './home-constants' +// HomeCreatorCenter stays in its own client module: it derives styles via +// buttonVariants(), which cannot be invoked inside this server component. +import HomeCreatorCenter from './home-creator-center' +import HomeGuide from './home-guide' +import { HomeStickyCatalogTabs } from './home-sticky-state-provider' +import styles from './home-sticky.module.css' + +type HomeHeaderProps = { + activeTab?: HomeCatalogTab | null + actions?: React.ReactNode + catalogLabels?: HomeCatalogTabLabels + isMarketplacePlatform: boolean + language?: string +} + +const HomeHeader = ({ + activeTab = 'plugins', + actions, + catalogLabels, + isMarketplacePlatform, + language, +}: HomeHeaderProps) => { + return ( + <header + className="sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 md:px-9" + style={{ height: HOME_HEADER_HEIGHT_PX }} + > + <div className="flex min-w-0 flex-1 items-center gap-4"> + <Link + // In the embedded console "/" leaves the marketplace entirely, so + // the brand mark points back at the marketplace home instead. + href={isMarketplacePlatform ? '/' : '/marketplace'} + aria-label="Dify Marketplace" + className="flex h-full w-[141.933px] shrink-0 items-center" + > + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoLight, + )} + height="16.386" + src={MarketplaceLogo.src} + width="141.761" + /> + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoDark, + )} + height="16.386" + src={MarketplaceLogoDark.src} + width="141.761" + /> + </Link> + <HomeStickyCatalogTabs> + <HomeCatalogTabs + activeTab={activeTab} + className={styles.headerCatalogTabs} + isMarketplacePlatform={isMarketplacePlatform} + labels={catalogLabels} + language={language} + /> + </HomeStickyCatalogTabs> + </div> + + <div className="flex h-full min-w-0 flex-1 items-center justify-end gap-2.5"> + <div + className={cn( + 'flex min-w-0 items-center gap-2.5', + isMarketplacePlatform && styles.standaloneHeaderActions, + )} + > + <HomeCreatorCenter /> + <HomeGuide isMarketplacePlatform={isMarketplacePlatform} /> + </div> + {actions} + </div> + </header> + ) +} + +export default HomeHeader diff --git a/web/app/components/plugins/marketplace/home/home-hero.module.css b/web/app/components/plugins/marketplace/home/home-hero.module.css new file mode 100644 index 00000000000..4b51646e05b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-hero.module.css @@ -0,0 +1,74 @@ +.decorations { + pointer-events: none; + position: absolute; + inset: 0; +} + +/* 4 grid rows (0–163) so the 40px icons at y=123 sit fully inside the hero + without overflowing into a scrollbar. */ +.frame { + height: 163px; +} + +/* 40px cells + 1px divider-subtle lines, matching Figma header/Variant2. + Figma's vertical lines are inset 141px on a 1512px canvas (~9%) and sit + under a white wash, so the grid fades out toward both edges instead of + meeting the viewport at full strength. + + The 41px tile is odd-sized, so `background-position: center` places the + 1px stroke on a half-pixel and leaves a 0.5px gap beside every icon. + +0.5px matches Figma (`left: calc(50% + 0.5px)`) so line starts sit on + the same pixels as `left: calc(50% + n * 41px)`. */ +.grid { + position: absolute; + inset: 0; + background-image: + linear-gradient( + to right, + transparent 20px, + var(--color-divider-subtle) 20px, + var(--color-divider-subtle) 21px, + transparent 21px + ), + linear-gradient(to bottom, var(--color-divider-subtle) 1px, transparent 1px); + background-size: + var(--hero-grid-pitch, 41px) 100%, + 100% var(--hero-grid-pitch, 41px); + background-position: + calc(50% + 0.5px) top, + left 40px; + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + #000 12%, + #000 88%, + transparent 100% + ); + mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%); +} + +/* Figma Ellipse 5 (1159:70851): 555×245 white oval at (478, 63) on the + 1512×257 header, layer-blur 60. Hero y is shifted −44px so the top icon + row sits at 0. The blur washes grid lines out under the title and search + while fading toward the decorative icons. */ +.glow { + position: absolute; + top: 19px; + left: 50%; + width: 555px; + height: 245px; + transform: translateX(-50%); + border-radius: 50%; + background: var(--color-background-default); + filter: blur(30px); +} + +@media (max-width: 879px) { + .decorations { + display: none; + } + + :global([data-marketplace-standalone]) .copyBlock { + max-width: 360px; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-hero.tsx b/web/app/components/plugins/marketplace/home/home-hero.tsx new file mode 100644 index 00000000000..3b685c340ba --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-hero.tsx @@ -0,0 +1,100 @@ +'use client' + +import type { CSSProperties, ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import brain2FillIcon from './assets/brain-2-fill.svg' +import imageCircleAiLineIcon from './assets/image-circle-ai-line.svg' +import plugFillIcon from './assets/plug-fill.svg' +import puzzleFillIcon from './assets/puzzle-fill.svg' +import sparklingFillIcon from './assets/sparkling-fill.svg' +import voiceAiFillIcon from './assets/voice-ai-fill.svg' +import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from './home-constants' +import styles from './home-hero.module.css' + +type HomeHeroProps = { + isMarketplacePlatform: boolean + subtitle?: ReactNode + title?: ReactNode +} + +type HeroDecorationIcon = { + left: number + src: string + top: number +} + +const heroIconSrc = (icon: { src: string } | string) => (typeof icon === 'string' ? icon : icon.src) + +// Positions are Figma offsets from the 1512px canvas center, with the top +// icon row shifted to y=0 so the marks sit in HomeHero instead of the header. +const heroDecorationIcons: HeroDecorationIcon[] = [ + { src: heroIconSrc(sparklingFillIcon), left: -450, top: HERO_GRID_PITCH_PX }, + { src: heroIconSrc(plugFillIcon), left: -286, top: 0 }, + { src: heroIconSrc(puzzleFillIcon), left: -327, top: HERO_GRID_PITCH_PX * 3 }, + { src: heroIconSrc(brain2FillIcon), left: 247, top: HERO_GRID_PITCH_PX * 2 }, + { src: heroIconSrc(imageCircleAiLineIcon), left: 370, top: HERO_GRID_PITCH_PX * 3 }, + { src: heroIconSrc(voiceAiFillIcon), left: 411, top: 0 }, +] + +const heroGridStyle = { + '--hero-grid-pitch': `${HERO_GRID_PITCH_PX}px`, +} as CSSProperties + +const HeroDecorations = () => ( + <div aria-hidden className={styles.decorations} style={heroGridStyle}> + <div className={styles.grid} /> + <div className={styles.glow} /> + {heroDecorationIcons.map((icon) => ( + <span + key={icon.src} + className="absolute flex items-center justify-center overflow-hidden bg-state-accent-hover" + style={{ + height: HERO_ICON_SIZE_PX, + left: `calc(50% + ${icon.left}px)`, + top: icon.top, + width: HERO_ICON_SIZE_PX, + }} + > + <span className="relative size-[24px] overflow-hidden"> + <img alt="" aria-hidden className="size-full" height={24} src={icon.src} width={24} /> + </span> + </span> + ))} + </div> +) + +const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => { + const { t } = useTranslation('plugin') + + return ( + <section + className={cn( + 'relative flex shrink-0 justify-center overflow-hidden bg-background-default px-4', + !isMarketplacePlatform && 'pt-6', + )} + > + <HeroDecorations /> + <div + className={cn('relative flex w-full max-w-[726px] flex-col items-center', styles.frame)} + style={{ paddingTop: HERO_GRID_PITCH_PX }} + > + <div + className={cn('flex w-full flex-col items-center gap-2 text-center', styles.copyBlock)} + > + <h1 + className="text-[28px] leading-[1.2] font-medium tracking-[-0.56px] text-text-primary" + style={{ fontFamily: "var(--font-family-brand, 'Söhne', var(--font-sans))" }} + > + {title ?? t(($) => $['marketplace.home.heroTitle'])} + </h1> + <p className="w-full text-[13px] leading-4 font-light tracking-[-0.065px] text-text-tertiary"> + {subtitle ?? t(($) => $['marketplace.home.heroSubtitle'])} + </p> + </div> + </div> + </section> + ) +} + +export default HomeHero diff --git a/web/app/components/plugins/marketplace/home/home-search.tsx b/web/app/components/plugins/marketplace/home/home-search.tsx new file mode 100644 index 00000000000..08d26678a7a --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-search.tsx @@ -0,0 +1,79 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import styles from './home-sticky.module.css' +import MarketplacePluginSearch from './marketplace-plugin-search' +import { preserveStickySearchScroll } from './preserve-sticky-search-scroll' + +type HomeSearchProps = { + children?: ReactNode + /** + * Registers the global Cmd/Ctrl+K focus shortcut. The embedded console + * already binds Mod+K to GotoAnything, so only the standalone marketplace + * should keep this enabled. + */ + enableSearchShortcut?: boolean + /** + * Pull the search row up over the hero. Search-results (and any other + * page without a hero) must leave this off so the field stays below the + * header instead of covering the brand. + */ + overlapHero?: boolean +} + +const HomeSearch = ({ + children, + enableSearchShortcut = true, + overlapHero = true, +}: HomeSearchProps) => { + const searchRef = useRef<HTMLDivElement>(null) + const { t } = useTranslation('plugin') + + useEffect(() => { + const searchRoot = searchRef.current + const container = document.getElementById(MARKETPLACE_CONTAINER_ID) + if (!searchRoot || !container) return + return preserveStickySearchScroll(searchRoot, container) + }, []) + + useEffect(() => { + if (!enableSearchShortcut) return + + const handleGlobalSearchShortcut = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return + + event.preventDefault() + searchRef.current?.querySelector('input')?.focus({ preventScroll: true }) + } + + document.addEventListener('keydown', handleGlobalSearchShortcut) + return () => document.removeEventListener('keydown', handleGlobalSearchShortcut) + }, [enableSearchShortcut]) + + return ( + <div + className={cn( + 'pointer-events-none flex shrink-0 justify-center', + overlapHero && '-mt-9', + styles.search, + )} + > + <div + ref={searchRef} + className={cn('pointer-events-auto relative w-full', styles.searchContent)} + > + {children ?? ( + <MarketplacePluginSearch + placeholder={t(($) => $['marketplace.home.searchPlaceholder'])} + /> + )} + </div> + </div> + ) +} + +export default HomeSearch diff --git a/web/app/components/plugins/marketplace/home/home-shell.tsx b/web/app/components/plugins/marketplace/home/home-shell.tsx new file mode 100644 index 00000000000..d5a1fe2d07b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-shell.tsx @@ -0,0 +1,77 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { CSSProperties, ReactNode } from 'react' +import type { MarketplaceBannerPage } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { + HOME_HEADER_HEIGHT_PX, + HOME_SEARCH_HEIGHT_PX, + HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, +} from './home-constants' +import { HomeStickyStateProvider } from './home-sticky-state-provider' +import styles from './home-sticky.module.css' +import HomeTrending from './home-trending' + +type HomeShellProps = { + banners: PluginBanner[] + children: ReactNode + header: ReactNode + hero: ReactNode + isMarketplacePlatform: boolean + navigation: ReactNode + page: MarketplaceBannerPage + search: ReactNode +} + +/** + * Shared scaffold for the marketplace catalog homes (Plugins and Templates): + * sticky header, hero, floating search, the optional trending banners, and + * the sticky catalog navigation above the page content. Keeping the structure + * in one place stops the two catalog pages from drifting apart. + */ +export function HomeShell({ + banners, + children, + header, + hero, + isMarketplacePlatform, + navigation, + page, + search, +}: HomeShellProps) { + return ( + <HomeStickyStateProvider> + <div + className="flex min-h-full w-full shrink-0 flex-col bg-background-default" + data-marketplace-standalone={isMarketplacePlatform ? '' : undefined} + style={ + { + '--home-header-height': `${HOME_HEADER_HEIGHT_PX}px`, + '--home-search-height': `${HOME_SEARCH_HEIGHT_PX}px`, + '--home-search-mobile-padding-bottom': `${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`, + } as CSSProperties + } + > + {header} + <div className="relative flex w-full flex-col"> + {hero} + {search} + {banners.length > 0 && ( + <> + <div + aria-hidden="true" + className={cn('h-12 shrink-0', isMarketplacePlatform && styles.bannerSpacer)} + /> + <HomeTrending + banners={banners} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + </> + )} + {navigation} + {children} + </div> + </div> + </HomeStickyStateProvider> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx b/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx new file mode 100644 index 00000000000..436a89707b6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' +import { ScopeProvider } from 'jotai-scope' +import { homeCatalogPinnedAtom, homeStickyScopedAtoms } from './home-sticky-state' +import styles from './home-sticky.module.css' + +export function HomeStickyStateProvider({ children }: { children: ReactNode }) { + return ( + <ScopeProvider atoms={homeStickyScopedAtoms} name="MarketplaceHomeSticky"> + {children} + </ScopeProvider> + ) +} + +export function HomeStickyCatalogTabs({ children }: { children: ReactNode }) { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( + <div + aria-hidden={!isCatalogPinned ? true : undefined} + className={cn(styles.headerCatalogSlot, isCatalogPinned && styles.headerCatalogSlotPinned)} + data-home-catalog-tabs-slot="header" + inert={!isCatalogPinned ? true : undefined} + > + {children} + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-sticky-state.ts b/web/app/components/plugins/marketplace/home/home-sticky-state.ts new file mode 100644 index 00000000000..e49206393a4 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-sticky-state.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai' + +export const homeCatalogPinnedAtom = atom(false) + +export const homeStickyScopedAtoms = [homeCatalogPinnedAtom] diff --git a/web/app/components/plugins/marketplace/home/home-sticky.module.css b/web/app/components/plugins/marketplace/home/home-sticky.module.css new file mode 100644 index 00000000000..e2f99827b1f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-sticky.module.css @@ -0,0 +1,171 @@ +/* Sticky offsets read --home-header-height and --home-search-height from + HomeShell (HOME_HEADER_HEIGHT_PX / HOME_SEARCH_HEIGHT_PX). Desktop catalog + navigation still pins with an inline top of HOME_HEADER_HEIGHT_PX. */ + +.headerCatalogTabs { + display: flex; +} + +.headerCatalogSlot, +.catalogTabs { + transition-property: opacity, transform; + transition-duration: 140ms; + transition-timing-function: ease-out; + will-change: opacity, transform; +} + +.headerCatalogSlot { + display: flex; + flex-shrink: 0; + opacity: 0; + pointer-events: none; + transform: translateY(4px); +} + +.headerCatalogSlotPinned { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.marketplaceLogoLight { + display: block; +} + +.marketplaceLogoDark { + display: none; +} + +:global(html[data-theme='dark']) .marketplaceLogoLight { + display: none; +} + +:global(html[data-theme='dark']) .marketplaceLogoDark { + display: block; +} + +.search { + position: sticky; + z-index: 60; + top: 6px; + height: var(--home-search-height, 36px); + padding-right: 356px; + padding-left: 356px; + overflow-anchor: none; +} + +.searchContent { + max-width: 420px; +} + +.catalogNavigationGroup { + display: contents; +} + +.catalogTabsRegion { + padding: 24px 32px 0; +} + +.catalogNavigation { + position: sticky; + z-index: 40; + padding: 16px 32px; +} + +.catalogNavigationPinned { + background-color: var(--color-background-default); +} + +.catalogTabs { + opacity: 1; + transform: translateY(0); +} + +.catalogTabsPinned { + opacity: 0; + pointer-events: none; + transform: translateY(-4px); +} + +.catalogContent { + min-height: calc(100vh - 106px); + min-height: calc(100dvh - 106px); +} + +@media (max-width: 879px) { + .search { + position: relative; + z-index: 0; + top: auto; + padding-right: 16px; + padding-left: 16px; + } + + :global([data-marketplace-standalone]) .search { + position: sticky; + z-index: 45; + top: var(--home-header-height, 48px); + height: calc(var(--home-search-height, 36px) + var(--home-search-mobile-padding-bottom, 16px)); + padding-right: 20px; + padding-bottom: var(--home-search-mobile-padding-bottom, 16px); + padding-left: 20px; + background-color: var(--color-background-default); + } + + :global([data-marketplace-standalone]) .searchContent { + max-width: 360px; + } + + :global([data-marketplace-standalone]) .headerCatalogTabs { + display: none; + } + + :global([data-marketplace-standalone]) .standaloneHeaderActions { + display: none; + } + + /* Sit under the search row's padding-bottom so that gap is not stacked + on top of the tabs' own padding when this group pins. */ + :global([data-marketplace-standalone]) .catalogNavigationGroup { + position: sticky; + z-index: 40; + display: block; + top: calc(var(--home-header-height, 48px) + var(--home-search-height, 36px)); + background-color: var(--color-background-default); + } + + :global([data-marketplace-standalone]) .catalogTabsRegion { + padding-top: var(--home-search-mobile-padding-bottom, 16px); + padding-right: 20px; + padding-left: 20px; + } + + :global([data-marketplace-standalone]) .catalogNavigation { + position: static; + padding: 16px 20px; + } + + :global([data-marketplace-standalone]) .catalogLeading { + display: none; + } + + :global([data-marketplace-standalone]) .catalogLeadingDivider { + display: none; + } + + :global([data-marketplace-standalone]) .catalogContent { + padding-right: 20px; + padding-left: 20px; + } + + :global([data-marketplace-standalone]) .bannerSpacer { + height: 24px; + } +} + +@media (prefers-reduced-motion: reduce) { + .headerCatalogSlot, + .catalogTabs { + transition-duration: 0ms; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx new file mode 100644 index 00000000000..4b065bfc89b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx @@ -0,0 +1,281 @@ +'use client' + +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { RefObject } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import styles from './home-trending.module.css' + +const AUTOPLAY_DELAY = 5000 +const PAGINATION_DOT_SIZE = 6 +const PAGINATION_ACTIVE_WIDTH = 40 +const PAGINATION_GAP = 8 +const PAGINATION_STEP = PAGINATION_DOT_SIZE + PAGINATION_GAP +const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE + +const getPaginationItemOffset = (index: number, selectedIndex: number) => + index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0) + +type AutoplayPauseReason = + | 'focus' + | 'hover' + | 'interaction' + | 'reduced-motion' + | 'user' + | 'viewport' + | 'visibility' + +function TrendingNavigation({ + banners, + selectedIndex, + carouselRootRef, + interactionPaused, + pauseWhenOffscreen, + onSelect, + onNext, + onPausedChange, +}: { + banners: PluginBanner[] + selectedIndex: number + carouselRootRef: RefObject<HTMLDivElement | null> + interactionPaused: boolean + pauseWhenOffscreen: boolean + onSelect: (index: number) => void + onNext: () => void + onPausedChange?: (paused: boolean) => void +}) { + const { t } = useTranslation('plugin') + const progressRef = useRef<HTMLSpanElement>(null) + const progressAnimationRef = useRef<Animation | null>(null) + const pauseReasonsRef = useRef( + new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []), + ) + const [isUserPaused, setIsUserPaused] = useState(false) + const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false) + const isExplicitlyPaused = isUserPaused || isReducedMotionPaused + const paginationWidth = + PAGINATION_ACTIVE_WIDTH + Math.max(0, banners.length - 1) * PAGINATION_STEP + + const setPauseReason = useCallback( + (reason: AutoplayPauseReason, shouldPause: boolean) => { + if (shouldPause) pauseReasonsRef.current.add(reason) + else pauseReasonsRef.current.delete(reason) + + const isPaused = pauseReasonsRef.current.size > 0 + onPausedChange?.(isPaused) + + const progressAnimation = progressAnimationRef.current + if (!progressAnimation) return + + if (isPaused) progressAnimation.pause() + else progressAnimation.play() + }, + [onPausedChange], + ) + + useEffect(() => { + setPauseReason('interaction', interactionPaused) + }, [interactionPaused, setPauseReason]) + + useEffect(() => { + const progressElement = progressRef.current + if (!progressElement?.animate) return + + const progressAnimation = progressElement.animate( + [{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], + { + duration: AUTOPLAY_DELAY, + easing: 'linear', + fill: 'forwards', + }, + ) + progressAnimationRef.current = progressAnimation + + if (pauseReasonsRef.current.size > 0) progressAnimation.pause() + progressAnimation.onfinish = onNext + + return () => { + progressAnimation.onfinish = null + progressAnimation.cancel() + if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null + } + }, [onNext, selectedIndex]) + + useEffect(() => { + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + const handleMouseEnter = () => setPauseReason('hover', true) + const handleMouseLeave = () => setPauseReason('hover', false) + const handleFocusIn = () => setPauseReason('focus', true) + const handleFocusOut = (event: FocusEvent) => { + if (carouselRoot.contains(event.relatedTarget as Node | null)) return + setPauseReason('focus', false) + } + const handleVisibilityChange = () => + setPauseReason('visibility', document.visibilityState === 'hidden') + + carouselRoot.addEventListener('mouseenter', handleMouseEnter) + carouselRoot.addEventListener('mouseleave', handleMouseLeave) + carouselRoot.addEventListener('focusin', handleFocusIn) + carouselRoot.addEventListener('focusout', handleFocusOut) + document.addEventListener('visibilitychange', handleVisibilityChange) + handleVisibilityChange() + + return () => { + carouselRoot.removeEventListener('mouseenter', handleMouseEnter) + carouselRoot.removeEventListener('mouseleave', handleMouseLeave) + carouselRoot.removeEventListener('focusin', handleFocusIn) + carouselRoot.removeEventListener('focusout', handleFocusOut) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [carouselRootRef, setPauseReason]) + + useEffect(() => { + if (!pauseWhenOffscreen) { + setPauseReason('viewport', false) + return + } + + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + if (typeof IntersectionObserver === 'undefined') { + setPauseReason('viewport', false) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + const isVisible = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25 + setPauseReason('viewport', !isVisible) + }, + { + root: document.getElementById(MARKETPLACE_CONTAINER_ID), + threshold: 0.25, + }, + ) + + observer.observe(carouselRoot) + + return () => observer.disconnect() + }, [carouselRootRef, pauseWhenOffscreen, setPauseReason]) + + useEffect(() => { + const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + const syncReducedMotion = () => { + // oxlint-disable-next-line eslint-react/set-state-in-effect -- This state mirrors an external media query. + setIsReducedMotionPaused(reducedMotionQuery.matches) + setPauseReason('reduced-motion', reducedMotionQuery.matches) + } + + syncReducedMotion() + reducedMotionQuery.addEventListener('change', syncReducedMotion) + + return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion) + }, [setPauseReason]) + + const clearImplicitPauseReasons = () => { + // Pointer activation leaves hover and/or focus on the control, which + // would otherwise keep rotation paused until the next mouseleave/focusout. + setPauseReason('focus', false) + setPauseReason('hover', false) + } + + const toggleAutoplay = () => { + if (isExplicitlyPaused) { + setIsUserPaused(false) + setIsReducedMotionPaused(false) + setPauseReason('user', false) + setPauseReason('reduced-motion', false) + // An explicit Play overrides the implicit reasons; they re-engage on + // the next mouseenter/focusin. + clearImplicitPauseReasons() + return + } + + setIsUserPaused(true) + setPauseReason('user', true) + } + + return ( + <div + role="group" + aria-label={t(($) => $['marketplace.home.trendingPaginationLabel'])} + className={cn( + styles.navigation, + 'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2', + )} + > + <div className="relative h-1.5 shrink-0" style={{ width: paginationWidth }}> + <span + aria-hidden + className="pointer-events-none absolute top-0 left-0 z-1 flex h-1.5 w-10 items-center overflow-hidden rounded-full bg-state-base-handle transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none" + style={{ + transform: `translate3d(${selectedIndex * PAGINATION_STEP}px, 0, 0)`, + }} + > + <span + key={selectedIndex} + ref={progressRef} + data-carousel-progress + className="h-full w-full rounded-full bg-text-accent" + style={{ transform: 'scaleX(0)', transformOrigin: 'left center' }} + /> + </span> + {banners.map((banner, index) => { + const isCurrent = index === selectedIndex + + return ( + <button + key={banner.id} + type="button" + aria-label={banner.title} + aria-current={isCurrent ? 'true' : undefined} + onClick={(event) => { + if (!isCurrent) onSelect(index) + // Keyboard selection keeps the focus pause so rotation does + // not advance under the user. Pointer selection should keep + // timing immediately without waiting for blur. + if (event.detail === 0) return + clearImplicitPauseReasons() + }} + className={cn( + 'absolute top-0 left-0 z-2 h-1.5 overflow-hidden rounded-full outline-hidden transition-[transform,width,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] after:absolute after:-inset-2 hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none', + isCurrent ? 'bg-transparent' : 'bg-state-base-handle', + )} + style={{ + width: isCurrent ? PAGINATION_ACTIVE_WIDTH : PAGINATION_DOT_SIZE, + transform: `translate3d(${getPaginationItemOffset(index, selectedIndex)}px, 0, 0)`, + }} + /> + ) + })} + </div> + <div className="min-w-0 flex-1" /> + <button + type="button" + aria-label={t( + ($) => + $[ + isExplicitlyPaused + ? 'marketplace.home.trendingPlay' + : 'marketplace.home.trendingPause' + ], + )} + onClick={toggleAutoplay} + className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-active text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + {isExplicitlyPaused ? ( + <span aria-hidden className="i-ri-play-large-fill size-2 opacity-30" /> + ) : ( + <span aria-hidden className="i-ri-pause-large-fill size-2 opacity-30" /> + )} + </button> + </div> + ) +} + +export default TrendingNavigation diff --git a/web/app/components/plugins/marketplace/home/home-trending-slides.tsx b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx new file mode 100644 index 00000000000..a23842ca8fc --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx @@ -0,0 +1,503 @@ +'use client' + +import type { + BannerAd, + BannerBlog, + BannerEvent, + BannerRecommend, + BannerRecommendCard, + PluginBanner, +} from '@dify/contracts/marketplace' +import type { MarketplaceBannerPage } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import { trackEvent } from '@/app/components/base/amplitude' +import Partner from '@/app/components/plugins/base/badges/partner' +import Verified from '@/app/components/plugins/base/badges/verified' +import { MARKETPLACE_API_PREFIX } from '@/config' +import Link from '@/next/link' +import { + rememberMarketplaceSiteReferrer, + trackMarketplaceSiteEvent, +} from '@/utils/marketplace-site-track' +import { getPluginLinkInMarketplace } from '../utils' +import background from './assets/background.webp' +import difyUpdatesArt from './assets/dify-updates-art.png' +import { + EMBEDDED_MOBILE_BANNER_MEDIA, + MARKETPLACE_MOBILE_BANNER_MEDIA, + marketplaceTabletBannerMedia, + resolveEventAdBannerImageSrcs, +} from './event-ad-banner-image' +import { buildMarketplaceBannerClickProperties } from './home-trending-track' +import styles from './home-trending.module.css' +import { sanitizeMarketplaceHref } from './marketplace-href' + +const getMarketplaceAssetURL = (path?: string) => { + if (!path) return '' + if (/^https?:\/\//.test(path) || path.startsWith('/_next/')) return path + + try { + const apiURL = new URL(MARKETPLACE_API_PREFIX) + if (path.startsWith('/api/')) return `${apiURL.origin}${path}` + return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}` + } catch { + return path + } +} + +const getLocalCardHref = (card: BannerRecommendCard) => { + if (card.item_type === 'plugin') { + const [organization, pluginName] = card.item_id.split('/') + if (organization && pluginName) + return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}` + } + + if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}` + + return '/' +} + +const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean) => { + if (isMarketplacePlatform) return getLocalCardHref(card) + const deliveryHref = card.link ? sanitizeMarketplaceHref(card.link) : null + if (deliveryHref) return deliveryHref + + // The embedded console has no local plugin detail route, so a plugin card + // without a delivery-provided link opens the marketplace site detail page. + if (card.item_type === 'plugin') { + const [organization, pluginName] = card.item_id.split('/') + if (organization && pluginName) + return getPluginLinkInMarketplace({ org: organization, name: pluginName, type: 'plugin' }) + } + + return getLocalCardHref(card) +} + +const getCardCreator = (card: BannerRecommendCard) => { + if (card.creator) return card.creator + if (card.item_type !== 'plugin') return '' + + return card.item_id.split('/')[0] || '' +} + +const getBannerFrameProps = (banner: PluginBanner, page: MarketplaceBannerPage) => ({ + banner_id: banner.id, + sort: banner.sort, + page, + language: banner.language, + style_type: banner.style_type, +}) + +const trackMarketplaceBannerClick = ( + banner: PluginBanner, + cardClick?: Parameters<typeof buildMarketplaceBannerClickProperties>[1], +) => { + trackMarketplaceSiteEvent( + 'marketplace_banner_click', + buildMarketplaceBannerClickProperties(banner, cardClick), + ) +} + +function TrendingCopy({ + banner, + isMarketplacePlatform, +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean +}) { + const { t } = useTranslation('plugin') + const heading = banner.content.heading || t(($) => $['marketplace.home.trendingTitle']) + const description = + banner.content.description || + banner.content.subheadings?.join(' · ') || + t(($) => $['marketplace.home.trendingDescription']) + + return ( + <div + className={cn( + styles.copy, + 'flex min-w-0 flex-col items-start overflow-hidden p-5', + isMarketplacePlatform ? styles.marketplaceCopy : styles.embeddedCopy, + )} + > + <div className="flex w-full flex-col items-start gap-2 overflow-hidden"> + <p className="shrink-0 rounded-sm bg-state-accent-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-accent"> + {banner.title} + </p> + <h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary"> + {heading} + </h2> + <p + className={cn( + styles.copyDescription, + 'w-full text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary', + )} + > + {description} + </p> + </div> + </div> + ) +} + +function TrendingCard({ + banner, + card, + isMarketplacePlatform, + page, +}: { + banner: BannerRecommend + card: BannerRecommendCard + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const iconURL = getMarketplaceAssetURL(card.icon_url) + const creator = getCardCreator(card) + const href = getCardHref(card, isMarketplacePlatform) + if (!href) return null + const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href) + const isPartner = card.badges?.includes('partner') + const isVerified = card.badges?.includes('verified') + + return ( + <Link + href={href} + target={opensInNewTab ? '_blank' : undefined} + rel={opensInNewTab ? 'noopener noreferrer' : undefined} + aria-label={card.display_name} + onClick={() => { + trackEvent('marketplace_banner_item_click', { + ...getBannerFrameProps(banner, page), + item_type: card.item_type, + item_id: card.item_id, + card_position: card.card_position, + theme_type: banner.content.theme_type, + auto_batch_id: card.auto_batch_id ?? null, + }) + rememberMarketplaceSiteReferrer(card.item_id, 'banner') + trackMarketplaceBannerClick(banner, { + item_id: card.item_id, + item_type: card.item_type, + link: href, + }) + }} + className={cn( + styles.card, + 'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + )} + > + <div + className={cn( + styles.cardIcon, + 'flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge', + )} + style={{ + backgroundColor: !iconURL ? card.icon_background : undefined, + }} + > + {iconURL ? ( + <img + src={iconURL} + width={40} + height={40} + alt="" + aria-hidden + className="size-full object-cover" + /> + ) : card.icon ? ( + <span className="text-xl leading-none">{card.icon}</span> + ) : ( + <span aria-hidden="true" className="i-ri-image-line size-5 text-text-quaternary" /> + )} + </div> + + <div className={cn(styles.cardMeta, 'flex w-full items-end gap-1')}> + <div className="flex min-w-0 flex-1 flex-col items-start gap-[3px]"> + <div className="flex w-full min-w-0 items-center gap-[3px]"> + <h3 className="min-w-0 truncate text-sm leading-[normal] font-medium text-text-primary"> + {card.display_name} + </h3> + {(isPartner || isVerified) && ( + <div className="flex shrink-0 items-start gap-[3.5px]"> + {isPartner && ( + <Partner className="size-3.5" text={t(($) => $['marketplace.partnerTip'])} /> + )} + {isVerified && ( + <Verified className="size-3.5" text={t(($) => $['marketplace.verifiedTip'])} /> + )} + </div> + )} + </div> + {creator && ( + <p className="w-full truncate text-xs leading-[normal] font-normal text-text-tertiary"> + {t(($) => $['marketplace.home.trendingByCreator'], { creator })} + </p> + )} + </div> + <span className="shrink-0 rounded-full bg-background-section-burn px-1.5 py-[3px] text-[10px] leading-3 font-normal text-text-primary"> + {t(($) => $['marketplace.home.trendingView'])} + </span> + </div> + </Link> + ) +} + +function TrendingRecommendationSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + return ( + <div + className={cn( + 'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body', + isMarketplacePlatform && styles.stackedSlide, + )} + > + <TrendingCopy banner={banner} isMarketplacePlatform={isMarketplacePlatform} /> + <div + className={cn( + styles.recommendVisual, + 'relative h-[200px] shrink-0 overflow-hidden rounded-xl bg-background-body', + isMarketplacePlatform && styles.stackedVisual, + )} + > + <img + src={background.src} + width={1600} + height={900} + alt="" + aria-hidden + className={cn( + styles.recommendBackdrop, + 'absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80', + )} + /> + <div + aria-hidden + className={cn( + styles.recommendBackdrop, + 'absolute inset-0 bg-text-accent mix-blend-color', + )} + /> + + <div className={cn(styles.recommendCards, 'relative z-10 h-full items-center')}> + {banner.content.cards.map((card) => ( + <TrendingCard + key={`${card.item_type}:${card.item_id}`} + banner={banner} + card={card} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ))} + </div> + </div> + </div> + ) +} + +function BlogBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerBlog + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const href = sanitizeMarketplaceHref(banner.content.link) + if (!href) return null + const opensInNewTab = /^https?:\/\//.test(href) + + return ( + <Link + href={href} + target={opensInNewTab ? '_blank' : undefined} + rel={opensInNewTab ? 'noopener noreferrer' : undefined} + onClick={() => { + trackEvent('marketplace_banner_click', getBannerFrameProps(banner, page)) + trackMarketplaceBannerClick(banner) + }} + aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], { + title: banner.content.blog_title, + })} + className={cn( + 'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isMarketplacePlatform && styles.stackedSlide, + )} + > + <div + className={cn( + 'flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5', + isMarketplacePlatform && styles.stackedCopy, + )} + > + <div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2"> + <div className="flex w-full min-w-0 items-center"> + <p + className={cn( + 'max-w-full min-w-0 rounded-sm bg-state-success-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-success', + isMarketplacePlatform && styles.blogTag, + )} + > + {banner.title} + </p> + </div> + <div className="flex min-h-0 w-full max-w-[800px] flex-1 flex-col items-start gap-3"> + <h2 + className={cn( + 'w-full min-w-0 shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary', + isMarketplacePlatform && styles.blogTitle, + )} + > + {banner.content.blog_title} + </h2> + <div + className={cn( + 'flex min-h-0 w-full flex-1 flex-col items-start gap-2', + isMarketplacePlatform && styles.stackedCopyMeta, + )} + > + {banner.content.subtitle && ( + <p + className={cn( + 'w-full min-w-0 shrink-0 text-[15px] leading-[18px] font-normal tracking-[-0.3px] text-text-primary', + isMarketplacePlatform && styles.blogSubtitle, + )} + > + {banner.content.subtitle} + </p> + )} + {banner.content.description && ( + <p + className={cn( + styles.updatesDescription, + 'min-h-0 w-full min-w-0 overflow-hidden text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary', + )} + > + {banner.content.description} + </p> + )} + <span + aria-hidden + className={cn( + 'flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2', + isMarketplacePlatform && styles.readMoreDesktop, + )} + > + <span>{t(($) => $['marketplace.home.trendingReadMore'])}</span> + <span className="i-ri-arrow-right-s-line size-4" /> + </span> + </div> + </div> + </div> + </div> + <img + src={difyUpdatesArt.src} + width={400} + height={200} + alt="" + aria-hidden + className={cn( + styles.updatesArt, + isMarketplacePlatform && styles.stackedVisual, + 'h-[200px] shrink-0 rounded-2xl object-cover object-left', + )} + /> + </Link> + ) +} + +function ImageBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerEvent | BannerAd + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const href = sanitizeMarketplaceHref(banner.content.link) + if (!href) return null + const resolved = resolveEventAdBannerImageSrcs({ + desktop: getMarketplaceAssetURL(banner.content.images.desktop), + tablet: getMarketplaceAssetURL(banner.content.images.tablet) || undefined, + mobile: getMarketplaceAssetURL(banner.content.images.mobile) || undefined, + }) + + return ( + <Link + href={href} + target="_blank" + rel="noopener noreferrer" + onClick={() => { + trackEvent('marketplace_banner_click', getBannerFrameProps(banner, page)) + trackMarketplaceBannerClick(banner) + }} + aria-label={banner.content.alt_text || banner.title} + className={cn( + 'block h-[200px] w-full overflow-hidden rounded-2xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isMarketplacePlatform && styles.imageSlide, + )} + > + <picture className="block size-full"> + <source + media={ + isMarketplacePlatform ? MARKETPLACE_MOBILE_BANNER_MEDIA : EMBEDDED_MOBILE_BANNER_MEDIA + } + srcSet={resolved.mobile} + /> + {resolved.tablet && ( + <source + media={marketplaceTabletBannerMedia(isMarketplacePlatform)} + srcSet={resolved.tablet} + /> + )} + <img + src={resolved.desktop} + width={1200} + height={200} + alt="" + aria-hidden + className="size-full object-cover object-left" + /> + </picture> + </Link> + ) +} + +export function HomeBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: PluginBanner + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + if (banner.style_type === 'blog') + return ( + <BlogBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + ) + + if (banner.style_type === 'event' || banner.style_type === 'ad') + return ( + <ImageBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + ) + + return ( + <TrendingRecommendationSlide + banner={banner} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts new file mode 100644 index 00000000000..9cb7d8c7261 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts @@ -0,0 +1,135 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { describe, expect, it } from 'vitest' +import { buildMarketplaceBannerClickProperties } from './home-trending-track' + +const recommendBanner: PluginBanner = { + id: 'banner-recommend', + style_type: 'recommend', + title: 'Trending', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + cards: [], + }, +} + +describe('buildMarketplaceBannerClickProperties', () => { + it('maps a recommendation card click to the site-event payload', () => { + expect( + buildMarketplaceBannerClickProperties(recommendBanner, { + item_id: 'langgenius/dropbox', + item_type: 'plugin', + link: '/plugin/langgenius/dropbox', + }), + ).toEqual({ + banner_id: 'banner-recommend', + title: 'Trending', + theme_type: 'most_popular', + click_target: 'recommendation', + sort: 0, + language: 'en', + item_id: 'langgenius/dropbox', + item_type: 'plugin', + link: '/plugin/langgenius/dropbox', + }) + }) + + it('maps newest recommendation theme to new_arrivals', () => { + expect( + buildMarketplaceBannerClickProperties( + { + ...recommendBanner, + content: { theme_type: 'newest', cards: [] }, + }, + { + item_id: 'tpl-1', + item_type: 'template', + link: '/templates?tid=tpl-1', + }, + ), + ).toMatchObject({ + theme_type: 'new_arrivals', + click_target: 'recommendation', + item_id: 'tpl-1', + item_type: 'template', + }) + }) + + it('reports blog frame clicks with target_type and without card fields', () => { + expect( + buildMarketplaceBannerClickProperties({ + id: 'banner-blog', + style_type: 'blog', + title: 'Dify Updates', + sort: 1, + language: 'zh', + content: { + blog_title: 'Launch', + link: 'https://dify.ai/blog', + link_target_type: 'github', + }, + }), + ).toEqual({ + banner_id: 'banner-blog', + title: 'Dify Updates', + click_target: 'blog', + sort: 1, + language: 'zh', + target_type: 'github', + link: 'https://dify.ai/blog', + }) + }) + + it('reports event frame clicks with activity_id only', () => { + expect( + buildMarketplaceBannerClickProperties({ + id: 'banner-event', + style_type: 'event', + title: 'Meetup', + sort: 2, + language: 'ja', + content: { + images: { desktop: '/event.png' }, + link: 'https://dify.ai/events', + activity_id: 'act-1', + }, + }), + ).toEqual({ + banner_id: 'banner-event', + title: 'Meetup', + click_target: 'event', + sort: 2, + language: 'ja', + activity_id: 'act-1', + link: 'https://dify.ai/events', + }) + }) + + it('reports ad frame clicks with partner and campaign ids', () => { + expect( + buildMarketplaceBannerClickProperties({ + id: 'banner-ad', + style_type: 'ad', + title: 'Partner', + sort: 3, + language: 'en', + content: { + images: { desktop: '/ad.png' }, + link: 'https://partner.example', + partner_id: 'acme', + campaign_id: 'spring', + }, + }), + ).toEqual({ + banner_id: 'banner-ad', + title: 'Partner', + click_target: 'ad', + sort: 3, + language: 'en', + partner_id: 'acme', + campaign_id: 'spring', + link: 'https://partner.example', + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/home-trending-track.ts b/web/app/components/plugins/marketplace/home/home-trending-track.ts new file mode 100644 index 00000000000..65b4321c230 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-track.ts @@ -0,0 +1,58 @@ +import type { BannerRecommendCard, PluginBanner } from '@dify/contracts/marketplace' + +const CLICK_TARGET_BY_STYLE = { + recommend: 'recommendation', + blog: 'blog', + event: 'event', + ad: 'ad', +} as const + +const THEME_TYPE_BY_BANNER = { + newest: 'new_arrivals', + hottest: 'most_popular', + partner: 'partner', +} as const + +export type MarketplaceBannerCardClick = Pick<BannerRecommendCard, 'item_id' | 'item_type'> & { + link: string +} + +const compact = (properties: Record<string, unknown>) => { + const next: Record<string, unknown> = {} + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined && value !== '') next[key] = value + } + return next +} + +export const buildMarketplaceBannerClickProperties = ( + banner: PluginBanner, + cardClick?: MarketplaceBannerCardClick, +) => { + const clickTarget = CLICK_TARGET_BY_STYLE[banner.style_type] + const properties: Record<string, unknown> = { + banner_id: banner.id, + title: banner.title, + click_target: clickTarget, + sort: banner.sort, + language: banner.language, + link: cardClick?.link ?? (banner.style_type === 'recommend' ? undefined : banner.content.link), + } + + if (banner.style_type === 'recommend') { + properties.theme_type = THEME_TYPE_BY_BANNER[banner.content.theme_type] + properties.item_id = cardClick?.item_id + properties.item_type = cardClick?.item_type + } + + if (banner.style_type === 'blog') properties.target_type = banner.content.link_target_type + + if (banner.style_type === 'event') properties.activity_id = banner.content.activity_id + + if (banner.style_type === 'ad') { + properties.partner_id = banner.content.partner_id + properties.campaign_id = banner.content.campaign_id + } + + return compact(properties) +} diff --git a/web/app/components/plugins/marketplace/home/home-trending.module.css b/web/app/components/plugins/marketplace/home/home-trending.module.css new file mode 100644 index 00000000000..34272cf272b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.module.css @@ -0,0 +1,315 @@ +.wrapper { + padding-bottom: 30px; +} + +.copy { + flex: none; + width: 36.9167%; + height: 200px; +} + +.recommendVisual { + flex: 1; + min-width: 0; + container-type: inline-size; +} + +.recommendCards { + display: flex; + justify-content: space-between; + gap: 12px; + overflow: hidden; + padding: 42px 36px; +} + +.navigation { + top: 208px; + width: 100%; +} + +.contentTrack { + transition: transform 400ms ease-out; +} + +.card { + flex: 1 1 161px; + width: auto; + min-width: 161px; + max-width: 210px; + box-shadow: 0 8px 7.2px -6px rgb(0 0 0 / 19%); + scroll-snap-align: start; +} + +@container (max-width: 751px) { + .recommendCards > .card:nth-child(n + 4) { + display: none; + } +} + +@container (max-width: 578px) { + .recommendCards > .card:nth-child(n + 3) { + display: none; + } +} + +@container (max-width: 405px) { + .recommendCards { + justify-content: flex-start; + overflow-x: auto; + scroll-snap-type: x proximity; + scrollbar-width: none; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + } + + .recommendCards::-webkit-scrollbar { + display: none; + } + + .recommendCards > .card:nth-child(n) { + display: flex; + flex: 0 0 161px; + } +} + +.updatesArt { + /* Keep the 400×200 art at design size on PC so shrinking the frame + clips overflow on the right instead of scaling the bitmap. */ + width: 400px; + max-width: 400px; + flex-shrink: 0; + object-position: left; +} + +/* Desktop: crop from the right so left-side artwork stays visible when the + 6:1 frame is narrower than the image. */ +.imageSlide :is(picture, img) { + object-position: left; +} + +/* Desktop and mobile: keep the green label on one line. The title wraps. */ +.blogTag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.blogTitle { + overflow-wrap: break-word; + white-space: normal; +} + +.updatesDescription { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +@media (prefers-reduced-motion: reduce) { + .contentTrack { + transition-duration: 0ms; + } +} + +@media (max-width: 879px) { + :global([data-marketplace-standalone]) .section { + padding-right: 20px; + padding-left: 20px; + } + + :global([data-marketplace-standalone]) .wrapper { + padding-bottom: 0; + } + + :global([data-marketplace-standalone]) .copy { + width: 100%; + height: 160px; + } + + :global([data-marketplace-standalone]) .navigation { + position: static; + top: auto; + width: 100%; + } + + /* Recommend mobile: 96px app icons (Figma 1026:24938), not desktop cards. */ + :global([data-marketplace-standalone]) .recommendBackdrop { + display: none; + } + + :global([data-marketplace-standalone]) .recommendCards { + justify-content: center; + gap: 20px; + overflow: hidden; + padding: 36px 12px; + } + + :global([data-marketplace-standalone]) .recommendCards > .card { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 96px; + min-width: 96px; + max-width: 96px; + height: 96px; + padding: 0; + overflow: visible; + background: transparent; + border-radius: 20px; + box-shadow: none; + } + + :global([data-marketplace-standalone]) .recommendCards > .card:nth-child(-n + 3) { + display: flex; + } + + :global([data-marketplace-standalone]) .recommendCards > .card:nth-child(n + 4) { + display: none; + } + + :global([data-marketplace-standalone]) .cardIcon { + width: 96px; + height: 96px; + border-color: var(--color-effects-icon-border); + border-radius: 20px; + box-shadow: + 0 0.5px 5px 0 var(--color-shadow-shadow-4), + 0 0.5px 2px -0.5px var(--color-shadow-shadow-4); + backdrop-filter: blur(5px); + } + + :global([data-marketplace-standalone]) .cardMeta { + display: none; + } + + :global([data-marketplace-standalone]) .copyDescription { + font-size: 15px; + letter-spacing: -0.075px; + } + + :global([data-marketplace-standalone]) .updatesArt { + width: 100%; + max-width: none; + height: 197px; + } + + :global([data-marketplace-standalone]) .carouselRoot { + display: flex; + flex-direction: column; + gap: 8px; + height: auto; + border-radius: 0; + } + + :global([data-marketplace-standalone]) .slideViewport { + height: auto; + touch-action: pan-y pinch-zoom; + } + + :global([data-marketplace-standalone]) .contentTrack { + height: auto; + align-items: flex-start; + } + + :global([data-marketplace-standalone]) .slide { + height: auto; + } + + /* Flex rows size to the tallest item. Collapse hidden slides so image + banners do not inherit the stacked blog/recommend height. */ + :global([data-marketplace-standalone]) .slideInactive { + height: 0; + overflow: hidden; + } + + :global([data-marketplace-standalone]) .stackedSlide { + display: flex; + flex-direction: column-reverse; + height: auto; + } + + :global([data-marketplace-standalone]) .stackedVisual { + flex: none; + width: 100%; + height: 197px; + border-radius: 16px; + } + + /* Recommend mobile: already-tinted crop, not the desktop image + mix-blend. */ + :global([data-marketplace-standalone]) .recommendVisual { + border-radius: 12px; + background-color: var(--color-text-accent); + background-image: url('./assets/recommend-mobile-backdrop.webp'); + background-repeat: no-repeat; + background-position: center; + background-size: cover; + } + + :global([data-marketplace-standalone]) .stackedCopy { + flex: none; + height: auto; + min-height: 160px; + padding: 20px; + overflow: hidden; + } + + :global([data-marketplace-standalone]) .stackedCopyMeta { + flex: none; + gap: 2px; + } + + :global([data-marketplace-standalone]) .blogSubtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :global([data-marketplace-standalone]) .updatesDescription { + flex-shrink: 0; + width: 100%; + min-width: 0; + height: 40px; + } + + /* Event/ad mobile: show the 800×721 poster whole (ops banner-meta), not + cover-cropped into the stacked 357px blog/recommend frame. */ + :global([data-marketplace-standalone]) .imageSlide { + height: auto; + aspect-ratio: 800 / 721; + } + + :global([data-marketplace-standalone]) .imageSlide :is(picture, img) { + width: 100%; + height: 100%; + object-fit: contain; + object-position: left; + } + + :global([data-marketplace-standalone]) .readMoreDesktop { + display: none; + } +} + +@media (min-width: 880px) { + /* Event/ad: keep the 6:1 bitmap at least 1200px wide so a narrower + overflow-hidden frame clips the right, not the left. */ + .imageSlide img { + min-width: 1200px; + max-width: none; + } +} + +@media (min-width: 1232px) { + .marketplaceCopy { + width: 443px; + } +} + +@media (min-width: 1260px) { + .embeddedCopy { + width: 431px; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx new file mode 100644 index 00000000000..b67c76f6a3d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -0,0 +1,397 @@ +'use client' + +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, + TransitionEvent, +} from 'react' +import type { MarketplaceBannerPage } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { trackEvent } from '@/app/components/base/amplitude' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import TrendingNavigation from './home-trending-navigation' +import { HomeBannerSlide } from './home-trending-slides' +import styles from './home-trending.module.css' +import { useBannerViewability } from './use-banner-viewability' + +type LoopPhase = 'idle' | 'resetting' | 'wrapping' +type GestureAxis = 'horizontal' | 'pending' | 'vertical' + +type SwipeGesture = { + axis: GestureAxis + pointerId: number + selectedIndex: number + startX: number + startY: number +} + +const MOBILE_VIEWPORT_QUERY = '(max-width: 879px)' +const GESTURE_AXIS_THRESHOLD = 8 +const MIN_SWIPE_THRESHOLD = 40 +const MAX_SWIPE_THRESHOLD = 64 + +function TrackedBannerSlide({ + banner, + isActive, + isDragging, + isMarketplacePlatform, + page, +}: { + banner: PluginBanner + isActive: boolean + isDragging: boolean + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const slideRef = useRef<HTMLDivElement>(null) + + useBannerViewability( + slideRef, + () => { + const properties = { + banner_id: banner.id, + sort: banner.sort, + page, + language: banner.language, + style_type: banner.style_type, + } + trackEvent('marketplace_banner_impression', properties) + trackMarketplaceSiteEvent('marketplace_banner_impression', properties) + }, + isActive, + ) + + return ( + <div + ref={slideRef} + role="group" + aria-roledescription="slide" + aria-label={banner.title} + aria-hidden={!isActive} + inert={!isActive} + className={cn( + 'h-full min-w-0 shrink-0 grow-0 basis-full', + isMarketplacePlatform && styles.slide, + isMarketplacePlatform && !isActive && !isDragging && styles.slideInactive, + )} + > + <HomeBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + </div> + ) +} + +function HomeTrending({ + banners, + isMarketplacePlatform, + page, +}: { + banners: PluginBanner[] + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const carouselRootRef = useRef<HTMLDivElement>(null) + const swipeGestureRef = useRef<SwipeGesture | null>(null) + const suppressClickRef = useRef(false) + const suppressClickTimerRef = useRef<number | null>(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const [trackIndex, setTrackIndex] = useState(0) + const [loopPhase, setLoopPhase] = useState<LoopPhase>('idle') + const [dragOffset, setDragOffset] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const [isGestureActive, setIsGestureActive] = useState(false) + const [isRotationPaused, setIsRotationPaused] = useState(false) + const selectSlide = useCallback((index: number) => { + setLoopPhase('idle') + setTrackIndex(index) + setSelectedIndex(index) + }, []) + const selectNextSlide = useCallback(() => { + if (selectedIndex < banners.length - 1) { + const nextIndex = selectedIndex + 1 + setTrackIndex(nextIndex) + setSelectedIndex(nextIndex) + return + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setTrackIndex(0) + setSelectedIndex(0) + return + } + + // Move forwards to a visual clone of the first slide. Once that + // transition completes, the track can snap back to the real first slide. + setLoopPhase('wrapping') + setTrackIndex(banners.length) + }, [banners.length, selectedIndex]) + + const handleTrackTransitionEnd = useCallback( + (event: TransitionEvent<HTMLDivElement>) => { + if (loopPhase !== 'wrapping' || event.target !== event.currentTarget) return + + setLoopPhase('resetting') + setTrackIndex(0) + setSelectedIndex(0) + }, + [loopPhase], + ) + + useEffect(() => { + if (loopPhase !== 'resetting') return + + let settled = false + const settle = () => { + if (settled) return + settled = true + setLoopPhase('idle') + } + + const frame = window.requestAnimationFrame(settle) + const timeout = window.setTimeout(settle, 50) + return () => { + window.cancelAnimationFrame(frame) + window.clearTimeout(timeout) + } + }, [loopPhase]) + + useEffect( + () => () => { + if (suppressClickTimerRef.current !== null) window.clearTimeout(suppressClickTimerRef.current) + }, + [], + ) + + const canStartSwipe = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => + isMarketplacePlatform && + banners.length > 1 && + loopPhase === 'idle' && + event.isPrimary && + event.pointerType === 'touch' && + window.matchMedia(MOBILE_VIEWPORT_QUERY).matches, + [banners.length, isMarketplacePlatform, loopPhase], + ) + + const handlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + if (!canStartSwipe(event)) return + + if (suppressClickTimerRef.current !== null) { + window.clearTimeout(suppressClickTimerRef.current) + suppressClickTimerRef.current = null + } + suppressClickRef.current = false + swipeGestureRef.current = { + axis: 'pending', + pointerId: event.pointerId, + selectedIndex, + startX: event.clientX, + startY: event.clientY, + } + setIsGestureActive(true) + }, + [canStartSwipe, selectedIndex], + ) + + const handlePointerMove = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + const gesture = swipeGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId) return + + const deltaX = event.clientX - gesture.startX + const deltaY = event.clientY - gesture.startY + + if (gesture.axis === 'pending') { + if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < GESTURE_AXIS_THRESHOLD) return + + if (Math.abs(deltaY) > Math.abs(deltaX)) { + gesture.axis = 'vertical' + setIsGestureActive(false) + return + } + + gesture.axis = 'horizontal' + setIsDragging(true) + try { + event.currentTarget.setPointerCapture(event.pointerId) + } catch { + // Touch pointers are implicitly captured; explicit capture is only a + // safeguard for browsers that retarget during a horizontal drag. + } + } + + if (gesture.axis !== 'horizontal') return + + const viewportWidth = event.currentTarget.getBoundingClientRect().width + const boundedOffset = Math.max(-viewportWidth, Math.min(viewportWidth, deltaX)) + const isPastStart = gesture.selectedIndex === 0 && boundedOffset > 0 + const isPastEnd = gesture.selectedIndex === banners.length - 1 && boundedOffset < 0 + setDragOffset(isPastStart || isPastEnd ? boundedOffset * 0.35 : boundedOffset) + }, + [banners.length], + ) + + const finishSwipe = useCallback( + (event: ReactPointerEvent<HTMLDivElement>, wasCanceled = false) => { + const gesture = swipeGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId) return + + const deltaX = event.clientX - gesture.startX + const wasHorizontal = gesture.axis === 'horizontal' + swipeGestureRef.current = null + setDragOffset(0) + setIsDragging(false) + setIsGestureActive(false) + + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId) + + if (!wasHorizontal) return + + // Once the gesture locks to the horizontal axis, suppress the browser's + // trailing click even if the finger returns near its starting point. + suppressClickRef.current = true + suppressClickTimerRef.current = window.setTimeout(() => { + suppressClickRef.current = false + suppressClickTimerRef.current = null + }, 0) + + if (wasCanceled) return + + const swipeThreshold = Math.min( + MAX_SWIPE_THRESHOLD, + Math.max(MIN_SWIPE_THRESHOLD, event.currentTarget.getBoundingClientRect().width * 0.12), + ) + if (Math.abs(deltaX) < swipeThreshold) return + + if (deltaX < 0 && gesture.selectedIndex < banners.length - 1) + selectSlide(gesture.selectedIndex + 1) + else if (deltaX > 0 && gesture.selectedIndex > 0) selectSlide(gesture.selectedIndex - 1) + }, + [banners.length, selectSlide], + ) + + const handleClickCapture = useCallback((event: ReactMouseEvent<HTMLDivElement>) => { + if (!suppressClickRef.current) return + + event.preventDefault() + event.stopPropagation() + suppressClickRef.current = false + if (suppressClickTimerRef.current !== null) { + window.clearTimeout(suppressClickTimerRef.current) + suppressClickTimerRef.current = null + } + }, []) + + if (banners.length === 0) return null + + return ( + <section + aria-label={t(($) => $['marketplace.home.trendingTitle'])} + className={cn( + 'shrink-0 bg-background-default pb-6', + isMarketplacePlatform ? 'px-4 min-[1232px]:px-0' : 'px-4 md:px-9', + isMarketplacePlatform && styles.section, + )} + > + <div + className={cn( + styles.wrapper, + 'mx-auto w-full', + isMarketplacePlatform ? 'max-w-[1200px]' : 'max-w-[1188px]', + )} + > + <div + // The pause boundary covers the whole carousel region, so hovering + // or focusing the navigation controls also stops the rotation. + ref={carouselRootRef} + role="region" + aria-roledescription="carousel" + aria-label={t(($) => $['marketplace.home.trendingTitle'])} + className={cn( + 'relative h-[200px] w-full rounded-2xl', + isMarketplacePlatform && styles.carouselRoot, + )} + data-home-trending-carousel-root + > + <div + className={cn( + 'h-full overflow-hidden rounded-2xl', + isMarketplacePlatform && styles.slideViewport, + )} + onClickCapture={handleClickCapture} + onPointerCancel={(event) => finishSwipe(event, true)} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={finishSwipe} + > + <div + // Keep automatic rotation silent for screen readers; announce + // the current slide only once rotation is paused or user-driven. + aria-live={isRotationPaused ? 'polite' : 'off'} + className={cn(styles.contentTrack, 'flex h-full')} + data-carousel-track + data-carousel-loop-phase={loopPhase} + onTransitionEnd={handleTrackTransitionEnd} + style={{ + transform: + dragOffset === 0 + ? `translate3d(-${trackIndex * 100}%, 0, 0)` + : `translate3d(calc(-${trackIndex * 100}% + ${dragOffset}px), 0, 0)`, + transition: loopPhase === 'resetting' || isDragging ? 'none' : undefined, + }} + > + {banners.map((banner, index) => ( + <TrackedBannerSlide + key={banner.id} + banner={banner} + isActive={index === selectedIndex} + isDragging={isDragging} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ))} + {loopPhase !== 'idle' && banners[0] && ( + <div + aria-hidden + inert + data-carousel-loop-clone + className={cn( + 'h-full min-w-0 shrink-0 grow-0 basis-full', + isMarketplacePlatform && styles.slide, + )} + > + <HomeBannerSlide + banner={banners[0]} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + </div> + )} + </div> + </div> + {/* A single banner has nothing to rotate through, so skip the + pagination/autoplay controls entirely. */} + {banners.length > 1 && ( + <TrendingNavigation + banners={banners} + selectedIndex={selectedIndex} + carouselRootRef={carouselRootRef} + pauseWhenOffscreen={!isMarketplacePlatform} + onSelect={selectSlide} + onNext={selectNextSlide} + onPausedChange={setIsRotationPaused} + interactionPaused={isGestureActive} + /> + )} + </div> + </div> + </section> + ) +} + +export default HomeTrending diff --git a/web/app/components/plugins/marketplace/home/index.tsx b/web/app/components/plugins/marketplace/home/index.tsx new file mode 100644 index 00000000000..c555b94e66d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/index.tsx @@ -0,0 +1,82 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ActivePluginType } from '../constants' +import type { HomeCatalogTabLabels } from './home-catalog-tabs' +import ListWrapper from '../list/list-wrapper' +import CatalogTagsFilter from './catalog-tags-filter' +import HomeCatalogNavigation from './home-catalog-navigation' +import HomeCatalogTabs from './home-catalog-tabs' +import HomeHeader from './home-header' +import HomeHero from './home-hero' +import HomeSearch from './home-search' +import { HomeShell } from './home-shell' +import styles from './home-sticky.module.css' + +type MarketplaceHomeProps = { + actions?: React.ReactNode + activePluginType?: ActivePluginType + banners: PluginBanner[] + catalogCategories?: React.ReactNode + catalogLabels?: HomeCatalogTabLabels + isMarketplacePlatform: boolean + language?: string + linkToMarketplaceDetail: boolean + search?: React.ReactNode + showInstallButton: boolean +} + +const MarketplaceHome = ({ + actions, + activePluginType, + banners, + catalogCategories, + catalogLabels, + isMarketplacePlatform, + language, + linkToMarketplaceDetail, + search, + showInstallButton, +}: MarketplaceHomeProps) => { + return ( + <HomeShell + banners={banners} + isMarketplacePlatform={isMarketplacePlatform} + page="plugins" + header={ + <HomeHeader + actions={actions} + catalogLabels={catalogLabels} + isMarketplacePlatform={isMarketplacePlatform} + language={language} + /> + } + hero={<HomeHero isMarketplacePlatform={isMarketplacePlatform} />} + search={<HomeSearch enableSearchShortcut={isMarketplacePlatform}>{search}</HomeSearch>} + navigation={ + <HomeCatalogNavigation + catalogCategories={catalogCategories} + catalogLeading={<CatalogTagsFilter />} + isMarketplacePlatform={isMarketplacePlatform} + catalogTabs={ + <HomeCatalogTabs + isMarketplacePlatform={isMarketplacePlatform} + labels={catalogLabels} + language={language} + /> + } + /> + } + > + <div className="contents [&>div]:bg-background-default!"> + <ListWrapper + activePluginType={activePluginType} + className={styles.catalogContent} + deferOffscreenCollections={!isMarketplacePlatform} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + /> + </div> + </HomeShell> + ) +} + +export default MarketplaceHome diff --git a/web/app/components/plugins/marketplace/home/marketplace-href.ts b/web/app/components/plugins/marketplace/home/marketplace-href.ts new file mode 100644 index 00000000000..91b992209b8 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-href.ts @@ -0,0 +1,15 @@ +export function sanitizeMarketplaceHref(value: string): string | null { + const trimmed = value.trim() + if (!trimmed) return null + if (trimmed.startsWith('/') && !trimmed.startsWith('//') && !trimmed.includes('\\')) { + return trimmed + } + + try { + const url = new URL(trimmed) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.toString() + } catch { + return null + } +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx new file mode 100644 index 00000000000..32a7b9eec93 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx @@ -0,0 +1,84 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { useDebounce } from 'ahooks' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useRouter } from '@/next/navigation' +import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track' + +type MarketplaceLiveSearchProps = { + action: string + className?: string + language?: string + placeholder: string + preserveParams?: { + tags?: string[] + languages?: string[] + } + query: string +} + +export default function MarketplaceLiveSearch({ + action, + className, + language, + placeholder, + preserveParams, + query, +}: MarketplaceLiveSearchProps) { + const router = useRouter() + const [value, setValue] = useState(query) + const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) + const routedSearchRef = useRef(query.trim()) + const navigate = useCallback( + (nextQuery: string) => { + if (nextQuery) markMarketplaceSiteSearch(nextQuery) + const searchParams = new URLSearchParams() + if (nextQuery) searchParams.set('q', nextQuery) + if (language) searchParams.set('language', language) + if (preserveParams?.tags?.length) searchParams.set('tags', preserveParams.tags.join(',')) + if (preserveParams?.languages?.length) + searchParams.set('languages', preserveParams.languages.join(',')) + const queryString = searchParams.toString() + + router.replace(`${action}${queryString ? `?${queryString}` : ''}`, { scroll: false }) + }, + [action, language, preserveParams, router], + ) + + useEffect(() => { + if (debouncedSearch === routedSearchRef.current) return + + routedSearchRef.current = debouncedSearch + navigate(debouncedSearch) + }, [debouncedSearch, navigate]) + + return ( + <form + action={action} + className={cn('relative shrink-0', className)} + onSubmit={(event) => { + event.preventDefault() + const nextQuery = value.trim() + routedSearchRef.current = nextQuery + navigate(nextQuery) + }} + > + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" + /> + <input + type="search" + name="q" + autoComplete="off" + aria-label={placeholder} + value={value} + onChange={(event) => setValue(event.target.value)} + placeholder={placeholder} + className="h-9 w-full rounded-[10px] border border-transparent bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active" + /> + {language && <input type="hidden" name="language" value={language} />} + </form> + ) +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx new file mode 100644 index 00000000000..86e72000551 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useSearchPluginText } from '../atoms' + +type MarketplacePluginSearchProps = { + placeholder: string +} + +export default function MarketplacePluginSearch({ placeholder }: MarketplacePluginSearchProps) { + const [value, setValue] = useSearchPluginText() + + return ( + <form + className="relative w-full shrink-0" + onSubmit={(event) => { + event.preventDefault() + }} + > + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" + /> + <input + type="search" + name="q" + autoComplete="off" + aria-label={placeholder} + value={value} + onChange={(event) => { + void setValue(event.target.value) + }} + placeholder={placeholder} + className="h-9 w-full rounded-[10px] border border-transparent bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active" + /> + </form> + ) +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx new file mode 100644 index 00000000000..8303ef0bfb8 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -0,0 +1,437 @@ +'use client' + +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import { + Autocomplete, + AutocompleteClear, + AutocompleteCollection, + AutocompleteEmpty, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteInput, + AutocompleteInputGroup, + AutocompleteItem, + AutocompleteItemText, + AutocompleteList, + AutocompletePortal, + AutocompletePositioner, + AutocompleteSeparator, + AutocompleteStatus, + useAutocompleteFilteredItems, +} from '@langgenius/dify-ui/autocomplete' +import { cn } from '@langgenius/dify-ui/cn' +import { useQuery } from '@tanstack/react-query' +import { useDebounce } from 'ahooks' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { renderI18nObject } from '@/i18n-config/index' +import { marketplaceQuery } from '@/service/client' +import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track' +import { getPluginIconInMarketplace } from '../utils' + +export type MarketplaceSearchScope = 'all' | 'plugins' | 'templates' + +export type MarketplaceSearchSelection = + | { kind: 'plugin'; plugin: MarketplacePlugin } + | { kind: 'template'; template: MarketplaceTemplate } + +type MarketplaceSuggestion = { + description: string + iconUrl?: string + id: string + kind: 'plugin' | 'template' + label: string + meta: string + selection: MarketplaceSearchSelection +} + +type MarketplaceSuggestionGroup = { + id: MarketplaceSuggestion['kind'] + items: MarketplaceSuggestion[] + label: string +} + +type MarketplaceSearchAutocompleteProps = { + category?: string + inputName?: string + locale: string + onSuggestionSelect?: (selection: MarketplaceSearchSelection) => void + onValueChange: (value: string) => void + placeholder: string + scope: MarketplaceSearchScope + value: string +} + +const getPluginText = ( + value: MarketplacePlugin['brief'] | MarketplacePlugin['label'], + locale: string, +) => { + if (typeof value === 'string') return value + return renderI18nObject((value ?? {}) as Record<string, string>, locale) +} + +const toTemplateSuggestion = (template: MarketplaceTemplate): MarketplaceSuggestion => ({ + description: template.overview, + iconUrl: template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` + : undefined, + id: `template:${template.id}`, + kind: 'template', + label: template.template_name, + meta: template.publisher_handle || template.publisher_unique_handle || '', + selection: { kind: 'template', template }, +}) + +const toPluginSuggestion = (plugin: MarketplacePlugin, locale: string): MarketplaceSuggestion => ({ + description: getPluginText(plugin.brief, locale), + iconUrl: getPluginIconInMarketplace(plugin), + id: `plugin:${plugin.org}/${plugin.name}`, + kind: 'plugin', + label: getPluginText(plugin.label, locale) || plugin.name, + meta: plugin.org, + selection: { kind: 'plugin', plugin }, +}) + +type MarketplaceSuggestionListProps = { + onSuggestionSelect?: (selection: MarketplaceSearchSelection) => void + onValueChange: (value: string) => void + setIsOpen: (isOpen: boolean) => void +} + +function MarketplaceSuggestionList({ + onSuggestionSelect, + onValueChange, + setIsOpen, +}: MarketplaceSuggestionListProps) { + const groups = useAutocompleteFilteredItems<MarketplaceSuggestionGroup>() + + return ( + <AutocompleteList className="max-h-none overflow-visible p-0 data-empty:p-0"> + {groups.map((group, groupIndex) => ( + <AutocompleteGroup key={group.id} items={group.items} className="p-1"> + {groupIndex > 0 && <AutocompleteSeparator className="-mx-1 mb-1" />} + <AutocompleteGroupLabel className="px-3 pt-3 pb-2 system-xs-semibold-uppercase text-text-primary"> + {group.label} + </AutocompleteGroupLabel> + <AutocompleteCollection<MarketplaceSuggestion>> + {(item) => ( + <AutocompleteItem + key={item.id} + value={item} + className="mx-0 items-start gap-1 rounded-lg py-1 pr-1 pl-3 hover:bg-state-base-hover data-highlighted:bg-state-base-hover" + onClick={ + onSuggestionSelect + ? () => { + onSuggestionSelect(item.selection) + queueMicrotask(() => { + onValueChange('') + setIsOpen(false) + }) + } + : undefined + } + > + <span className="flex shrink-0 items-start py-1"> + {item.iconUrl ? ( + <img + alt="" + className={cn( + 'shrink-0 object-contain', + item.kind === 'template' + ? 'size-8 rounded-lg border-[0.5px] border-divider-regular' + : 'size-7 rounded-lg', + )} + src={item.iconUrl} + onError={({ currentTarget }) => { + currentTarget.style.display = 'none' + }} + /> + ) : ( + <span + aria-hidden + className={cn( + 'flex shrink-0 items-center justify-center text-text-tertiary', + item.kind === 'template' + ? 'i-ri-layout-grid-line size-8 rounded-lg border-[0.5px] border-divider-regular text-base' + : 'i-ri-puzzle-2-line size-7 rounded-lg text-base', + )} + /> + )} + </span> + <span className="flex min-w-0 flex-1 flex-col gap-0.5 p-1"> + <AutocompleteItemText className="px-0 system-md-medium text-text-primary"> + {item.label} + </AutocompleteItemText> + {!!item.description && ( + <span className="line-clamp-2 system-xs-regular text-text-tertiary"> + {item.description} + </span> + )} + {!!item.meta && ( + <span className="truncate pt-1 system-xs-regular text-text-tertiary"> + {item.meta} + </span> + )} + </span> + </AutocompleteItem> + )} + </AutocompleteCollection> + </AutocompleteGroup> + ))} + </AutocompleteList> + ) +} + +export function MarketplaceSearchAutocomplete({ + category = 'all', + inputName, + locale, + onSuggestionSelect, + onValueChange, + placeholder, + scope, + value, +}: MarketplaceSearchAutocompleteProps) { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const searchRootRef = useRef<HTMLDivElement>(null) + const resultsPanelRef = useRef<HTMLDivElement>(null) + const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) + const hasQuery = Boolean(debouncedSearch) + const searchesPlugins = scope === 'all' || scope === 'plugins' + const searchesTemplates = scope === 'all' || scope === 'templates' + const isBundleSearch = category === 'bundle' + const pluginQuery = useQuery({ + ...marketplaceQuery.searchAdvanced.queryOptions({ + input: { + params: { kind: isBundleSearch ? 'bundles' : 'plugins' }, + body: { + page: 1, + page_size: 5, + query: debouncedSearch, + sort_by: 'install_count', + sort_order: 'DESC', + category: category !== 'all' && !isBundleSearch ? category : '', + }, + }, + retry: false, + }), + // No placeholderData here: showing the previous term's suggestions would + // leave stale items keyboard-selectable while the new request is pending. + enabled: hasQuery && searchesPlugins, + staleTime: 60_000, + }) + const templateQuery = useQuery({ + ...marketplaceQuery.templateSearch.queryOptions({ + input: { + body: { + page: 1, + page_size: 5, + query: debouncedSearch, + sort_by: 'usage_count', + sort_order: 'DESC', + ...(category !== 'all' ? { categories: [category] } : {}), + }, + }, + retry: false, + }), + enabled: hasQuery && searchesTemplates, + staleTime: 60_000, + }) + // While the edited value is still debouncing, the queries above still hold + // the previous term's data; gate the suggestions until both agree so stale + // options are never visible or keyboard-selectable. + const isDebouncing = value.trim() !== debouncedSearch + const pluginSuggestions = + !isDebouncing && searchesPlugins + ? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) => + toPluginSuggestion(plugin, locale), + ) + : [] + const templateSuggestions = + !isDebouncing && searchesTemplates + ? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion) + : [] + const suggestions = [...templateSuggestions, ...pluginSuggestions] + const suggestionGroups: MarketplaceSuggestionGroup[] = [ + ...(templateSuggestions.length + ? [ + { + id: 'template' as const, + items: templateSuggestions, + label: t(($) => $['marketplace.home.templates'], { ns: 'plugin' }), + }, + ] + : []), + ...(pluginSuggestions.length + ? [ + { + id: 'plugin' as const, + items: pluginSuggestions, + label: t(($) => $['marketplace.home.plugins'], { ns: 'plugin' }), + }, + ] + : []), + ] + const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching + // Keep open tied to the typing session so outside-press can dismiss during + // debounce/fetch. Pending, empty, and error copy live inside the popup. + const hasTypedQuery = Boolean(value.trim()) + const isPopupOpen = isOpen && hasTypedQuery + // A failed request must not read as "nothing matched"; when every source in + // scope errored and nothing is displayable, surface a load failure instead. + const hasLoadError = + !isDebouncing && + suggestions.length === 0 && + ((searchesPlugins && pluginQuery.isError) || (searchesTemplates && templateQuery.isError)) + const emptyText = hasLoadError + ? t(($) => $['marketplace.loadError'], { ns: 'plugin' }) + : scope === 'templates' + ? t(($) => $['newApp.noTemplateFound'], { ns: 'app' }) + : t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' }) + + useEffect(() => { + if (!isPopupOpen) return + + const handleOutsidePress = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (searchRootRef.current?.contains(target) || resultsPanelRef.current?.contains(target)) + return + setIsOpen(false) + } + + document.addEventListener('click', handleOutsidePress) + return () => document.removeEventListener('click', handleOutsidePress) + }, [isPopupOpen]) + + return ( + <div ref={searchRootRef} className="relative"> + <Autocomplete + filter={null} + itemToStringValue={(item) => item.label} + items={suggestionGroups} + mode="list" + name={inputName} + onOpenChange={setIsOpen} + onValueChange={(nextValue) => { + onValueChange(nextValue) + setIsOpen(Boolean(nextValue.trim())) + }} + open={isPopupOpen} + openOnInputClick + submitOnItemClick={Boolean(inputName)} + value={value} + > + <AutocompleteInputGroup size="large"> + <span + aria-hidden + className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <AutocompleteInput + aria-label={placeholder} + className="px-2 text-sm" + placeholder={placeholder} + size="large" + type="text" + /> + {!!value && ( + <AutocompleteClear + aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })} + size="large" + /> + )} + </AutocompleteInputGroup> + <AutocompletePortal hidden={!isPopupOpen}> + <AutocompletePositioner sideOffset={8}> + <div + ref={resultsPanelRef} + className="max-h-[min(710px,var(--available-height))] w-[472px] max-w-[min(calc(100vw-32px),var(--available-width))] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl outline-hidden backdrop-blur-sm" + aria-busy={isSearching || undefined} + > + <MarketplaceSuggestionList + onSuggestionSelect={onSuggestionSelect} + onValueChange={onValueChange} + setIsOpen={setIsOpen} + /> + <AutocompleteEmpty> + {!isSearching && suggestions.length === 0 ? emptyText : null} + </AutocompleteEmpty> + <AutocompleteStatus className="empty:h-0 empty:p-0"> + {isSearching ? t(($) => $.loading, { ns: 'common' }) : null} + </AutocompleteStatus> + {Boolean(inputName) && suggestions.length > 0 && !isSearching && ( + <div className="border-t border-divider-subtle p-1"> + <button + type="button" + className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" + onClick={() => { + const form = searchRootRef.current?.closest('form') + if (form instanceof HTMLFormElement) form.requestSubmit() + }} + > + <span className="system-sm-medium text-text-accent"> + {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + </span> + <span + aria-hidden + className="rounded-[5px] border border-divider-deep px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary group-hover:hidden" + > + Enter + </span> + </button> + </div> + )} + </div> + </AutocompletePositioner> + </AutocompletePortal> + </Autocomplete> + </div> + ) +} + +type MarketplaceSearchFormProps = { + action: string + category?: string + className?: string + language?: string + locale: string + placeholder: string + query: string + scope: MarketplaceSearchScope +} + +export function MarketplaceSearchForm({ + action, + category, + className, + language, + locale, + placeholder, + query, + scope, +}: MarketplaceSearchFormProps) { + const [value, setValue] = useState(query) + + return ( + <form + action={action} + className={cn('relative shrink-0', className)} + onSubmit={() => { + markMarketplaceSiteSearch(value) + }} + > + <MarketplaceSearchAutocomplete + category={category} + inputName="q" + locale={locale} + onValueChange={setValue} + placeholder={placeholder} + scope={scope} + value={value} + /> + {language && <input type="hidden" name="language" value={language} />} + </form> + ) +} diff --git a/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts new file mode 100644 index 00000000000..cd4e3167833 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts @@ -0,0 +1,99 @@ +const LARGE_SCROLL_JUMP_PX = 16 + +/** + * Sticky search sits in document flow below the hero, then visually pins in the + * header. Focusing or typing in that input makes Chromium scroll the layout box + * into view, which unpins the search and looks like the page rolling down. + * Remember the scroll position and snap back when a focused search input causes + * a large jump. + */ +export function preserveStickySearchScroll(searchRoot: HTMLElement, container: HTMLElement) { + let stableScrollTop = container.scrollTop + let suppressing = false + + const remember = () => { + if (!suppressing) stableScrollTop = container.scrollTop + } + + const restore = () => { + if (container.scrollTop === stableScrollTop) return + suppressing = true + container.scrollTop = stableScrollTop + requestAnimationFrame(() => { + suppressing = false + }) + } + + const onScroll = () => { + if (suppressing) return + if (searchRoot.contains(document.activeElement)) { + if (Math.abs(container.scrollTop - stableScrollTop) > LARGE_SCROLL_JUMP_PX) { + restore() + return + } + } + remember() + } + + const onPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + remember() + suppressing = true + requestAnimationFrame(() => { + restore() + suppressing = false + }) + } + + const onFocusIn = (event: FocusEvent) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + restore() + requestAnimationFrame(restore) + } + + const onInput = (event: Event) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + restore() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + remember() + suppressing = true + requestAnimationFrame(() => { + suppressing = false + }) + } + + const patchInputFocus = (input: HTMLInputElement) => { + if (input.dataset.marketplaceSearchFocus === 'patched') return + input.dataset.marketplaceSearchFocus = 'patched' + const nativeFocus = input.focus.bind(input) + input.focus = (options) => nativeFocus({ ...options, preventScroll: true }) + } + + searchRoot.querySelectorAll('input').forEach((input) => { + patchInputFocus(input) + }) + const observer = new MutationObserver(() => { + searchRoot.querySelectorAll('input').forEach((input) => { + patchInputFocus(input) + }) + }) + observer.observe(searchRoot, { childList: true, subtree: true }) + + container.addEventListener('scroll', onScroll, { passive: true }) + searchRoot.addEventListener('pointerdown', onPointerDown, true) + searchRoot.addEventListener('focusin', onFocusIn) + searchRoot.addEventListener('input', onInput, true) + window.addEventListener('keydown', onKeyDown, true) + + return () => { + observer.disconnect() + container.removeEventListener('scroll', onScroll) + searchRoot.removeEventListener('pointerdown', onPointerDown, true) + searchRoot.removeEventListener('focusin', onFocusIn) + searchRoot.removeEventListener('input', onInput, true) + window.removeEventListener('keydown', onKeyDown, true) + } +} diff --git a/web/app/components/plugins/marketplace/home/use-banner-viewability.ts b/web/app/components/plugins/marketplace/home/use-banner-viewability.ts new file mode 100644 index 00000000000..4a2fdc5e356 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/use-banner-viewability.ts @@ -0,0 +1,58 @@ +import type { RefObject } from 'react' +import { useEffect, useRef } from 'react' + +const BANNER_VIEWABILITY_THRESHOLD = 0.5 +const BANNER_VIEWABILITY_DWELL_MS = 1000 + +export function useBannerViewability( + targetRef: RefObject<Element | null>, + onImpression: () => void, + enabled = true, +) { + const onImpressionRef = useRef(onImpression) + onImpressionRef.current = onImpression + + useEffect(() => { + if (!enabled) return + + const target = targetRef.current + if (!target || typeof IntersectionObserver === 'undefined') return + + let dwellTimer: ReturnType<typeof setTimeout> | undefined + let didImpress = false + + const clearDwell = () => { + if (dwellTimer === undefined) return + clearTimeout(dwellTimer) + dwellTimer = undefined + } + + const observer = new IntersectionObserver( + ([entry]) => { + const isViewable = (entry?.intersectionRatio ?? 0) >= BANNER_VIEWABILITY_THRESHOLD + + if (!isViewable) { + didImpress = false + clearDwell() + return + } + + if (didImpress || dwellTimer !== undefined) return + + dwellTimer = setTimeout(() => { + dwellTimer = undefined + didImpress = true + onImpressionRef.current() + }, BANNER_VIEWABILITY_DWELL_MS) + }, + { threshold: BANNER_VIEWABILITY_THRESHOLD }, + ) + + observer.observe(target) + + return () => { + clearDwell() + observer.disconnect() + } + }, [enabled, targetRef]) +} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 455ae83dd92..df0a0ea8a11 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -5,11 +5,11 @@ import type { PluginsSearchParams, } from '@dify/contracts/marketplace' import type { Plugin } from '../types' -import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { postMarketplace } from '@/service/base' -import { SCROLL_BOTTOM_THRESHOLD } from './constants' +import { MARKETPLACE_CONTAINER_ID, SCROLL_BOTTOM_THRESHOLD } from './constants' import { getFormattedPlugin, getMarketplaceCollectionsAndPlugins, @@ -81,7 +81,6 @@ export const useMarketplacePluginsByCollectionId = ( * @deprecated Use useMarketplacePlugins from query.ts instead */ export const useMarketplacePlugins = (enabled = true) => { - const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState<PluginsSearchParams>() const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => { @@ -156,12 +155,9 @@ export const useMarketplacePlugins = (enabled = true) => { retry: false, }) - const resetPlugins = useCallback(() => { + const resetQueryParams = useCallback(() => { setQueryParams(undefined) - queryClient.removeQueries({ - queryKey: ['marketplacePlugins'], - }) - }, [queryClient]) + }, []) const handleUpdatePlugins = useCallback( (pluginsSearchParams: PluginsSearchParams) => { @@ -195,7 +191,7 @@ export const useMarketplacePlugins = (enabled = true) => { return { plugins, total, - resetPlugins, + resetQueryParams, queryPlugins: handleUpdatePlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced, @@ -211,24 +207,40 @@ export const useMarketplacePlugins = (enabled = true) => { export const useMarketplaceContainerScroll = ( callback: () => void, - scrollContainerId = 'marketplace-container', + scrollContainerId = MARKETPLACE_CONTAINER_ID, ) => { - const handleScroll = useCallback( - (e: Event) => { - const target = e.target as HTMLDivElement - const { scrollTop, scrollHeight, clientHeight } = target - if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) - callback() - }, - [callback], - ) + // The callback closes over isFetching, so its identity flips on every fetch + // boundary. Re-subscribing on each flip dropped the scroll events in that + // window; a ref keeps one listener for the container's lifetime. + const callbackRef = useRef(callback) + callbackRef.current = callback useEffect(() => { const container = document.getElementById(scrollContainerId) - if (container) container.addEventListener('scroll', handleScroll) + if (!container) return + + // scrollTop/scrollHeight/clientHeight force a synchronous layout, so + // measuring per scroll event janks the scroll. Worse, every threshold hit + // calls fetchNextPage, which defaults to cancelRefetch: true — a burst + // aborts and restarts the in-flight page request, and the backend counts + // those aborts against its search circuit breaker. One measurement per + // frame is both smoother and quieter on the wire. + let frame = 0 + const handleScroll = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + const { scrollTop, scrollHeight, clientHeight } = container + if (scrollTop > 0 && scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD) + callbackRef.current() + }) + } + + container.addEventListener('scroll', handleScroll, { passive: true }) return () => { - if (container) container.removeEventListener('scroll', handleScroll) + if (frame) cancelAnimationFrame(frame) + container.removeEventListener('scroll', handleScroll) } - }, [handleScroll]) + }, [scrollContainerId]) } diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index 9da59135d56..a54da0b6e29 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -5,7 +5,13 @@ import { createLoader } from 'nuqs/server' import { getQueryClient } from '@/app/get-query-client' import { marketplaceQuery } from '@/service/client' import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { marketplaceSearchParamsParsers } from './search-params' +import { getMarketplacePluginsInfiniteQueryOptions } from './query-options' +import { + getMarketplacePluginsSearchParams, + marketplaceSearchParamsParsers, + shouldSearchMarketplacePlugins, +} from './search-params' +import { withinServerBudget } from './server-budget' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' // The server side logic should move to marketplace's codebase so that we can get rid of Next.js @@ -17,18 +23,27 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) { const loadSearchParams = createLoader(marketplaceSearchParamsParsers) const params: MarketplaceSearchParams = await loadSearchParams(searchParams) - if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { - return - } - const queryClient = getQueryClient() - await queryClient.prefetchQuery({ - queryKey: marketplaceQuery.collections.queryKey({ - input: { query: getCollectionsParams(params.category) }, + if (shouldSearchMarketplacePlugins(params)) { + await withinServerBudget( + queryClient.prefetchInfiniteQuery( + getMarketplacePluginsInfiniteQueryOptions(getMarketplacePluginsSearchParams(params)), + ), + ) + return dehydrate(queryClient) + } + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) return + + await withinServerBudget( + queryClient.prefetchQuery({ + queryKey: marketplaceQuery.collections.queryKey({ + input: { query: getCollectionsParams(params.category) }, + }), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), }), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) + ) return dehydrate(queryClient) } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 98f1edd8de5..59cc0b5aee1 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,17 +1,14 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' import type { SearchParams } from 'nuqs' -import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' -import { TanStackQueryProvider } from '@/app/query-provider' -import Description from './description' +import type { MarketplaceViewProps } from './view' +import { getLocaleOnServer } from '@/i18n-config/server' +import { fetchPluginBanners } from './home/banners' import { HydrateQueryClient } from './hydration-server' -import ListWrapper from './list/list-wrapper' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' +import { withinServerBudget } from './server-budget' +import { MarketplaceView } from './view' -type MarketplaceProps = { - showInstallButton?: boolean - linkToMarketplaceDetail?: boolean - pluginTypeSwitchClassName?: string - isMarketplacePlatform?: boolean - marketplaceNav?: React.ReactNode +type MarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & { + language?: string /** * Pass the search params from the request to prefetch data on the server. */ @@ -19,31 +16,39 @@ type MarketplaceProps = { } const Marketplace = async ({ - showInstallButton = false, - linkToMarketplaceDetail = false, - pluginTypeSwitchClassName, - isMarketplacePlatform = false, - marketplaceNav, + language, searchParams, + variant = 'default', + ...viewProps }: MarketplaceProps) => { + let trendingBanners: PluginBanner[] = [] + + if (variant === 'home') { + const locale = language ?? (await getLocaleOnServer()) + + // Banners are decoration on a page whose point is the catalog, so the same + // budget that keeps the prefetch from holding the document applies here. + // A late resolution just misses this render; nothing waits on it. + await withinServerBudget( + fetchPluginBanners(locale) + .then((banners) => { + trendingBanners = banners + }) + .catch(() => { + // Keep the homepage available if Marketplace banner delivery is down. + }), + ) + } + return ( - <TanStackQueryProvider> - <HydrateQueryClient searchParams={searchParams}> - <PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}> - <Description - isMarketplacePlatform={isMarketplacePlatform} - marketplaceNav={marketplaceNav} - /> - {!isMarketplacePlatform && ( - <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> - )} - <ListWrapper - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - /> - </PluginInstallPermissionProviderGuard> - </HydrateQueryClient> - </TanStackQueryProvider> + <HydrateQueryClient searchParams={searchParams}> + <MarketplaceView + {...viewProps} + banners={trendingBanners} + language={language} + variant={variant} + /> + </HydrateQueryClient> ) } diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx index d61071aadc7..66f58412a5d 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -45,10 +45,34 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permis useOptionalPluginInstallPermission: () => ({ canInstallPlugin: true }), })) +vi.mock('../../detail-dialog', () => ({ + default: ({ + isInstalled, + open, + onInstall, + onOpenChange, + }: { + isInstalled: boolean + open: boolean + onInstall: () => void + onOpenChange: (open: boolean) => void + }) => + open ? ( + <div role="dialog" aria-label="marketplace detail" data-installed={isInstalled}> + {!isInstalled && ( + <button type="button" onClick={onInstall}> + install from detail + </button> + )} + <button type="button" onClick={() => onOpenChange(false)}> + close detail + </button> + </div> + ) : null, +})) + vi.mock('../../utils', () => ({ getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`, - getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => - `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, })) const plugin = { @@ -91,6 +115,7 @@ describe('CardWrapper', () => { renderCardWrapper() expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(document.querySelector('[data-marketplace-card="plugin-a"]')).toBeInTheDocument() expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent') }) @@ -107,7 +132,7 @@ describe('CardWrapper', () => { screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }), ).toBeInTheDocument() expect( - screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' }), + screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }), ).toBeInTheDocument() }) @@ -122,13 +147,18 @@ describe('CardWrapper', () => { expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() }) - it('links the detail action to the marketplace', () => { - renderCardWrapper({ showInstallButton: true }) + it('opens and closes marketplace detail dialog from the detail action', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true, isInstalled: true }) - const link = screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' }) - expect(link).toHaveAttribute('href', '/marketplace/dify/plugin-a?language=en-US&theme=system') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' })) + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toHaveAttribute( + 'data-installed', + 'true', + ) + + await user.click(screen.getByRole('button', { name: 'close detail' })) + expect(screen.queryByRole('dialog', { name: 'marketplace detail' })).not.toBeInTheDocument() }) it('opens and closes install modal from install action', () => { @@ -140,4 +170,15 @@ describe('CardWrapper', () => { fireEvent.click(screen.getByTestId('close-install-modal')) expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() }) + + it('opens the same install modal from the marketplace detail dialog', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' })) + await user.click(screen.getByRole('button', { name: 'install from detail' })) + + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + }) }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx new file mode 100644 index 00000000000..1b884005871 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx @@ -0,0 +1,371 @@ +import type { CarouselPage } from '../carousel' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Carousel from '../carousel' + +const mocks = vi.hoisted(() => { + const listeners = new Map<string, Set<() => void>>() + const carouselState = { + scrollSnaps: [0, 1, 2, 3, 4], + selectedIndex: 0, + } + const api = { + off: vi.fn((event: string, listener: () => void) => { + listeners.get(event)?.delete(listener) + }), + on: vi.fn((event: string, listener: () => void) => { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + }), + scrollNext: vi.fn(), + scrollPrev: vi.fn(), + scrollSnapList: vi.fn(() => carouselState.scrollSnaps), + scrollTo: vi.fn(), + selectedScrollSnap: vi.fn(() => carouselState.selectedIndex), + } + const autoplayInstances: { play: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }[] = [] + const autoplayOptions: Record<string, unknown>[] = [] + + return { + api, + autoplayInstances, + autoplayOptions, + carouselState, + emit: (event: string) => listeners.get(event)?.forEach((listener) => listener()), + listeners, + } +}) + +vi.mock('embla-carousel-react', () => ({ + default: () => [vi.fn(), mocks.api], +})) + +vi.mock('embla-carousel-autoplay', () => ({ + default: (options: Record<string, unknown>) => { + const instance = { play: vi.fn(), stop: vi.fn() } + mocks.autoplayOptions.push(options) + mocks.autoplayInstances.push(instance) + return instance + }, +})) + +const pages: CarouselPage[] = Array.from({ length: 5 }, (_, index) => ({ + id: `page-${index + 1}`, + content: <div>Page content {index + 1}</div>, +})) + +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType<typeof vi.fn> + observe: ReturnType<typeof vi.fn> + options?: IntersectionObserverInit +} + +const intersectionObservers: IntersectionObserverRecord[] = [] + +class MockIntersectionObserver { + callback: IntersectionObserverCallback + disconnect = vi.fn() + observe = vi.fn() + options?: IntersectionObserverInit + root: Element | Document | null + rootMargin: string + takeRecords = vi.fn(() => []) + thresholds: readonly number[] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + this.options = options + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + intersectionObservers.push(this) + } +} + +const installIntersectionObserver = () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) +} + +const triggerIntersection = (record: IntersectionObserverRecord, intersectionRatio: number) => { + act(() => { + record.callback( + [ + { + intersectionRatio, + isIntersecting: intersectionRatio > 0, + } as IntersectionObserverEntry, + ], + record as unknown as IntersectionObserver, + ) + }) +} + +describe('Marketplace Carousel', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listeners.clear() + mocks.autoplayInstances.length = 0 + mocks.autoplayOptions.length = 0 + mocks.carouselState.scrollSnaps = [0, 1, 2, 3, 4] + mocks.carouselState.selectedIndex = 0 + intersectionObservers.length = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps every slide shell while mounting only the current and adjacent pages', () => { + const { rerender } = render(<Carousel pages={pages} deferMountPages />) + + expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(5) + expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(3) + expect(screen.getByText('Page content 1')).toBeInTheDocument() + expect(screen.getByText('Page content 2')).toBeInTheDocument() + expect(screen.getByText('Page content 5')).toBeInTheDocument() + expect(screen.queryByText('Page content 3')).not.toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.goToPage:{"page":4}' }), + ) + + expect(screen.getByText('Page content 3')).toBeInTheDocument() + expect(screen.getByText('Page content 4')).toBeInTheDocument() + expect(mocks.api.scrollTo).toHaveBeenCalledWith(3) + + mocks.carouselState.selectedIndex = 3 + act(() => mocks.emit('select')) + mocks.carouselState.selectedIndex = 0 + act(() => mocks.emit('select')) + + expect(screen.getByText('Page content 4')).toBeInTheDocument() + + rerender(<Carousel pages={pages.slice(0, 3)} deferMountPages />) + + expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(3) + expect(screen.getByText('Page content 3')).toBeInTheDocument() + }) + + it('keeps eager consumers fully mounted and preserves loop navigation', () => { + render(<Carousel pages={pages} />) + + expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(5) + + fireEvent.click( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollPrevious' }), + ) + fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' })) + + expect(mocks.api.scrollPrev).toHaveBeenCalledOnce() + expect(mocks.api.scrollNext).toHaveBeenCalledOnce() + }) + + it('plays managed autoplay only while the carousel is visible and motion is allowed', () => { + installIntersectionObserver() + const marketplaceContainer = document.createElement('div') + marketplaceContainer.id = 'marketplace-container' + document.body.appendChild(marketplaceContainer) + let reducedMotion = false + let reducedMotionListener: (() => void) | undefined + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return reducedMotion + }, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + reducedMotionListener = listener + }, + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + const { unmount } = render( + <Carousel pages={pages} autoPlay deferMountPages pauseWhenOffscreen />, + { container: marketplaceContainer }, + ) + const autoplay = mocks.autoplayInstances[0]! + const carousel = screen.getByRole('region') + + expect(mocks.autoplayOptions[0]).toMatchObject({ + playOnInit: false, + stopOnInteraction: false, + stopOnMouseEnter: false, + }) + expect(intersectionObservers[0]!.options).toEqual({ + root: marketplaceContainer, + threshold: 0.25, + }) + expect(autoplay.stop).toHaveBeenCalled() + + triggerIntersection(intersectionObservers[0]!, 0.24) + triggerIntersection(intersectionObservers[0]!, 0.25) + expect(autoplay.play).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(carousel) + expect(autoplay.stop).toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + fireEvent(document, new Event('visibilitychange')) + fireEvent.mouseLeave(carousel) + expect(autoplay.play).toHaveBeenCalledOnce() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + fireEvent(document, new Event('visibilitychange')) + expect(autoplay.play).toHaveBeenCalledTimes(2) + + reducedMotion = true + act(() => reducedMotionListener?.()) + expect(autoplay.stop).toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + fireEvent(document, new Event('visibilitychange')) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + fireEvent(document, new Event('visibilitychange')) + expect(autoplay.play).toHaveBeenCalledTimes(2) + + reducedMotion = false + act(() => reducedMotionListener?.()) + expect(autoplay.play).toHaveBeenCalledTimes(3) + + triggerIntersection(intersectionObservers[0]!, 0) + expect(autoplay.stop).toHaveBeenCalled() + + unmount() + marketplaceContainer.remove() + }) + + it('preserves standalone autoplay initialization', () => { + render(<Carousel pages={pages} autoPlay />) + + expect(mocks.autoplayOptions[0]).toMatchObject({ + playOnInit: true, + stopOnMouseEnter: true, + }) + expect(intersectionObservers).toHaveLength(0) + }) + + it('honors reduced motion for the eagerly playing first-collection carousel', () => { + let reducedMotion = true + let reducedMotionListener: (() => void) | undefined + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return reducedMotion + }, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + reducedMotionListener = listener + }, + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + // The production first collection renders without pauseWhenOffscreen, so + // the reduced-motion guard must work outside the viewport-managed path. + render(<Carousel pages={pages} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + + expect(autoplay.stop).toHaveBeenCalled() + expect(autoplay.play).not.toHaveBeenCalled() + + reducedMotion = false + act(() => reducedMotionListener?.()) + + expect(autoplay.play).toHaveBeenCalled() + }) + + it('keeps off-screen pages out of the tab order and accessibility tree', () => { + render(<Carousel pages={pages} ariaLabel="Featured tools" />) + + expect(screen.getByRole('region', { name: 'Featured tools' })).toBeInTheDocument() + + const slides = document.querySelectorAll('[data-carousel-page]') + expect(slides[0]).toHaveAttribute('aria-roledescription', 'slide') + expect(slides[0]).toHaveAttribute('aria-label', '1 / 5') + expect(slides[0]).not.toHaveAttribute('aria-hidden', 'true') + expect(slides[0]).not.toHaveAttribute('inert') + expect(slides[1]).toHaveAttribute('aria-hidden', 'true') + expect(slides[1]).toHaveAttribute('inert') + + mocks.carouselState.selectedIndex = 3 + act(() => mocks.emit('select')) + + expect(slides[0]).toHaveAttribute('aria-hidden', 'true') + expect(slides[0]).toHaveAttribute('inert') + expect(slides[3]).not.toHaveAttribute('aria-hidden', 'true') + expect(slides[3]).not.toHaveAttribute('inert') + }) + + it('stops rotation for the rest of the session once focus enters', () => { + render(<Carousel pages={pages} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + const carousel = screen.getByRole('region') + + // The controls expose only the pagination dots and the two nav arrows. + expect(screen.getAllByRole('button')).toHaveLength(7) + + const playsBeforeFocus = autoplay.play.mock.calls.length + fireEvent.focusIn(carousel) + + expect(autoplay.stop).toHaveBeenCalled() + expect(autoplay.play).toHaveBeenCalledTimes(playsBeforeFocus) + + // Moving focus around does not resume rotation on its own. + fireEvent.focusIn(carousel) + expect(autoplay.play).toHaveBeenCalledTimes(playsBeforeFocus) + }) + + it('does not start managed autoplay when the carousel has only one page', () => { + installIntersectionObserver() + mocks.carouselState.scrollSnaps = [0] + + render(<Carousel pages={pages.slice(0, 1)} autoPlay deferMountPages pauseWhenOffscreen />) + const autoplay = mocks.autoplayInstances[0]! + + triggerIntersection(intersectionObservers[0]!, 1) + + expect(autoplay.play).not.toHaveBeenCalled() + }) + + // The autoplay plugin skips its own setup on single-page carousels, so an + // external play() call would crash inside the plugin (undefined delay list). + it('does not start eager autoplay when the carousel has only one page', () => { + mocks.carouselState.scrollSnaps = [0] + + render(<Carousel pages={pages.slice(0, 1)} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + + expect(autoplay.play).not.toHaveBeenCalled() + expect(autoplay.stop).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx new file mode 100644 index 00000000000..c939c9ea73a --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx @@ -0,0 +1,204 @@ +import type { MarketplaceCollection } from '@dify/contracts/marketplace' +import type { Plugin } from '@/app/components/plugins/types' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import ListWithCollection from '../list-with-collection' + +const mockState = vi.hoisted(() => ({ + becomePartnerText: 'Become a Partner', +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.carousel.scrollPrevious': 'Previous', + } + + return { + useLocale: () => 'en-US', + useTranslation: () => ({ + t: withSelectorKey((key: string) => + key === 'marketplace.becomePartner' + ? mockState.becomePartnerText + : (translations[key] ?? key), + ), + }), + } +}) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale, +})) + +vi.mock('../../atoms', () => ({ + useMarketplaceMoreClick: () => vi.fn(), +})) + +vi.mock('../card-wrapper', () => ({ + default: ({ plugin }: { plugin: Plugin }) => <div>{plugin.name}</div>, +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + trackMarketplaceSiteEvent: vi.fn(), +})) + +const partnerCollection: MarketplaceCollection = { + name: 'partners', + label: { 'en-US': 'Partners' }, + description: { 'en-US': 'Plugins verified by Dify partners.' }, + rule: 'partners', + created_at: '', + updated_at: '', + searchable: false, + search_params: {}, +} + +const partnerPlugins = Array.from({ length: 9 }, (_, index) => ({ + plugin_id: `partner-${index}`, + name: `Partner plugin ${index}`, +})) as Plugin[] + +const renderPartnerCollection = ({ + pluginCount = 9, + standalone = true, + width = 350, +}: { + pluginCount?: number + standalone?: boolean + width?: number +} = {}) => + render( + <div + data-testid="collection-shell" + data-marketplace-standalone={standalone || undefined} + style={{ width }} + > + <ListWithCollection + marketplaceCollections={[partnerCollection]} + marketplaceCollectionPluginsMap={{ partners: partnerPlugins.slice(0, pluginCount) }} + /> + </div>, + ) + +const getTextRect = (element: Element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect() +} + +describe('Partner collection header layout', () => { + beforeEach(() => { + mockState.becomePartnerText = 'Become a Partner' + }) + + it('keeps the mobile call to action beside the title and clear of carousel controls', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection() + + const title = screen.getByText('Partners', { exact: true }).element() + const description = screen.getByText('Plugins verified by Dify partners.').element() + const separator = screen.getByText('|').element() + const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element() + const previousButton = screen.getByRole('button', { name: 'Previous' }).element() + + const titleRect = getTextRect(title) + const descriptionRect = description.getBoundingClientRect() + const partnerLinkRect = partnerLink.getBoundingClientRect() + const previousButtonRect = previousButton.getBoundingClientRect() + + const titleCenter = titleRect.top + titleRect.height / 2 + const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2 + + expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2) + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8) + expect(descriptionRect.top).toBeGreaterThanOrEqual( + Math.max(titleRect.bottom, partnerLinkRect.bottom), + ) + expect(getComputedStyle(separator).display).toBe('none') + }) + + it('keeps the mobile action 12px from the title when navigation is absent', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection({ pluginCount: 2 }) + + const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect() + const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element()) + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right) + expect(screen.getByRole('button', { name: 'Previous' }).query()).toBeNull() + }) + + it('keeps the mobile action clear of navigation at a 320px viewport', async () => { + await page.viewport(320, 844) + mockState.becomePartnerText = 'Torne-se um parceiro' + const screen = await renderPartnerCollection({ width: 280 }) + + const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element()) + const partnerLinkRect = screen + .getByRole('link', { name: 'Torne-se um parceiro' }) + .element() + .getBoundingClientRect() + const previousButtonRect = screen + .getByRole('button', { name: 'Previous' }) + .element() + .getBoundingClientRect() + + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8) + }) + + it('preserves the narrow embedded metadata row', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection({ standalone: false }) + + const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect() + const descriptionRect = screen + .getByText('Plugins verified by Dify partners.') + .element() + .getBoundingClientRect() + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2) + expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right) + expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none') + }) + + it('preserves the desktop title and metadata rows', async () => { + await page.viewport(1280, 900) + const screen = await render( + <div className="w-[1200px]" data-marketplace-standalone> + <ListWithCollection + marketplaceCollections={[partnerCollection]} + marketplaceCollectionPluginsMap={{ partners: partnerPlugins }} + /> + </div>, + ) + + const titleRect = screen + .getByText('Partners', { exact: true }) + .element() + .getBoundingClientRect() + const descriptionRect = screen + .getByText('Plugins verified by Dify partners.') + .element() + .getBoundingClientRect() + const separator = screen.getByText('|').element() + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom) + expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2) + expect(getComputedStyle(separator).display).not.toBe('none') + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx index b617891a833..2603f49b3ea 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx @@ -1,7 +1,7 @@ import type { MarketplaceCollection } from '@dify/contracts/marketplace' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import ListWithCollection from '../list-with-collection' const mockMoreClick = vi.fn() @@ -48,9 +48,80 @@ const pluginsMap: Record<string, Plugin[]> = { empty: [], } +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType<typeof vi.fn> + observe: ReturnType<typeof vi.fn> + options?: IntersectionObserverInit +} + +const intersectionObservers: IntersectionObserverRecord[] = [] + +class MockIntersectionObserver { + callback: IntersectionObserverCallback + disconnect = vi.fn() + observe = vi.fn() + options?: IntersectionObserverInit + root: Element | Document | null + rootMargin: string + takeRecords = vi.fn(() => []) + thresholds: readonly number[] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + this.options = options + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + intersectionObservers.push(this) + } +} + +const installIntersectionObserver = () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) +} + +const triggerIntersection = ( + observer: IntersectionObserverRecord, + { intersectionRatio, isIntersecting }: { intersectionRatio: number; isIntersecting: boolean }, +) => { + act(() => { + observer.callback( + [{ intersectionRatio, isIntersecting } as IntersectionObserverEntry], + observer as unknown as IntersectionObserver, + ) + }) +} + +const buildPerformanceFixture = () => { + const pluginCounts = [61, 8, 8, 8, 8, 8, 8] + const fixtureCollections = pluginCounts.map((_, collectionIndex) => ({ + ...collections[0]!, + name: `collection-${collectionIndex}`, + label: { 'en-US': `Collection ${collectionIndex}` }, + description: { 'en-US': `Description ${collectionIndex}` }, + })) as MarketplaceCollection[] + const fixturePluginsMap = Object.fromEntries( + pluginCounts.map((pluginCount, collectionIndex) => [ + `collection-${collectionIndex}`, + Array.from({ length: pluginCount }, (_, pluginIndex) => ({ + plugin_id: `collection-${collectionIndex}-plugin-${pluginIndex}`, + name: `Collection ${collectionIndex} Plugin ${pluginIndex}`, + })) as Plugin[], + ]), + ) + + return { fixtureCollections, fixturePluginsMap } +} + describe('ListWithCollection', () => { beforeEach(() => { vi.clearAllMocks() + intersectionObservers.length = 0 + installIntersectionObserver() Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, @@ -58,6 +129,10 @@ describe('ListWithCollection', () => { }) }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('renders only collections that contain plugins', () => { render( <ListWithCollection @@ -201,7 +276,9 @@ describe('ListWithCollection', () => { ) expect(screen.queryByText('plugin.marketplace.viewMore')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Scroll right' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }), + ).toBeInTheDocument() const carousel = screen.getByRole('region') const carouselViewport = carousel.querySelector('.overflow-hidden') const carouselContent = carouselViewport?.firstElementChild @@ -209,4 +286,94 @@ describe('ListWithCollection', () => { expect(carouselViewport).toHaveClass('overflow-hidden', 'rounded-[inherit]') expect(carouselContent).toHaveStyle({ columnGap: '12px' }) }) + + it('keeps the first collection eager and defers the rest until they enter the preload range', () => { + const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture() + const marketplaceContainer = document.createElement('div') + marketplaceContainer.id = 'marketplace-container' + document.body.appendChild(marketplaceContainer) + + const { unmount } = render( + <ListWithCollection + marketplaceCollections={fixtureCollections} + marketplaceCollectionPluginsMap={fixturePluginsMap} + deferOffscreenCollections + />, + { container: marketplaceContainer }, + ) + + expect(screen.getAllByText(/Collection \d$/)).toHaveLength(7) + expect(document.querySelectorAll('[data-marketplace-collection]')).toHaveLength(7) + // The first (above-the-fold) collection renders its real cards immediately + // so server-rendered HTML contains first-screen content; the six remaining + // collections keep placeholders until they intersect. + expect( + document.querySelectorAll('[data-marketplace-collection-placeholder] > div'), + ).toHaveLength(48) + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(61) + expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(8) + expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(8) + // Partner collections autoplay; this fixture is non-partner, so the only + // observers here are the collection preload observers. + const collectionObservers = intersectionObservers.filter( + (observer) => observer.options?.rootMargin === '320px 0px', + ) + expect(collectionObservers).toHaveLength(6) + expect(collectionObservers[0]!.options).toEqual({ + root: marketplaceContainer, + rootMargin: '320px 0px', + threshold: 0.01, + }) + + triggerIntersection(collectionObservers[0]!, { + intersectionRatio: 0.01, + isIntersecting: true, + }) + + expect(collectionObservers[0]!.disconnect).toHaveBeenCalled() + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(69) + + triggerIntersection(collectionObservers[0]!, { + intersectionRatio: 0, + isIntersecting: false, + }) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(69) + + unmount() + marketplaceContainer.remove() + }) + + it('mounts deferred collections after hydration when IntersectionObserver is unavailable', () => { + vi.stubGlobal('IntersectionObserver', undefined) + + render( + <ListWithCollection + marketplaceCollections={collections} + marketplaceCollectionPluginsMap={pluginsMap} + deferOffscreenCollections + />, + ) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2) + expect( + document.querySelector('[data-marketplace-collection-placeholder]'), + ).not.toBeInTheDocument() + }) + + it('keeps standalone collections eager for SSR-compatible rendering', () => { + const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture() + + render( + <ListWithCollection + marketplaceCollections={fixtureCollections} + marketplaceCollectionPluginsMap={fixturePluginsMap} + />, + ) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(109) + expect( + intersectionObservers.some((observer) => observer.options?.rootMargin === '320px 0px'), + ).toBe(false) + }) }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx new file mode 100644 index 00000000000..0714b7cd62e --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx @@ -0,0 +1,101 @@ +import type { MarketplaceCollection } from '@dify/contracts/marketplace' +import type { Plugin } from '@/app/components/plugins/types' +import { useState } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import ListWrapper from '../list-wrapper' + +const mockMarketplaceData = vi.hoisted(() => ({ + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>, + isLoading: false, + isRefreshing: false, + isError: false, + refetch: vi.fn(), + isFetchingNextPage: false, + page: 1, +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (_selector: unknown, options?: Record<string, unknown>) => + `${options?.num ?? 0} plugins found`, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () => <div>loading</div>, +})) + +vi.mock('../../sort-dropdown', () => ({ + default: () => <div>sort</div>, +})) + +vi.mock('../index', () => ({ + default: () => ( + <div data-testid="catalog-results" style={{ height: 900, paddingTop: 80 }}> + <span>Catalog result anchor</span> + </div> + ), +})) + +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../../atoms', () => ({ + useSearchPluginText: () => [''], +})) + +function SearchResultsHarness() { + const [searchVersion, setSearchVersion] = useState(0) + + return ( + <div + data-search-version={searchVersion} + data-testid="marketplace-scroll-container" + style={{ height: 320, overflowY: 'auto' }} + > + <button + type="button" + style={{ position: 'sticky', top: 0, zIndex: 1 }} + onClick={() => { + mockMarketplaceData.plugins = [{ plugin_id: 'plugin-1', name: 'Search result' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + setSearchVersion((version) => version + 1) + }} + > + Type search + </button> + <div aria-hidden style={{ height: 220 }} /> + <ListWrapper /> + </div> + ) +} + +describe('Marketplace result scroll anchoring', () => { + beforeEach(() => { + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + }) + + // Scroll anchoring is owned by Chromium's layout engine and cannot be + // represented faithfully by the happy-dom unit project. + it('does not move the page when the first search result header appears', async () => { + await page.viewport(1280, 720) + const screen = await render(<SearchResultsHarness />) + const scrollContainer = screen.getByTestId('marketplace-scroll-container').element() + + scrollContainer.scrollTop = 260 + await new Promise(requestAnimationFrame) + const scrollTopBefore = scrollContainer.scrollTop + + await screen.getByRole('button', { name: 'Type search' }).click() + await expect.element(screen.getByText('1 plugins found')).toBeVisible() + await new Promise(requestAnimationFrame) + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx index 3b882a804d2..b011e742797 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx @@ -1,7 +1,10 @@ import type { MarketplaceCollection } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' import type { Plugin } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import ListWrapper from '../list-wrapper' const mockMarketplaceData = vi.hoisted(() => ({ @@ -10,6 +13,9 @@ const mockMarketplaceData = vi.hoisted(() => ({ marketplaceCollections: [] as MarketplaceCollection[], marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>, isLoading: false, + isRefreshing: false, + isError: false, + refetch: vi.fn(), isFetchingNextPage: false, page: 1, })) @@ -18,21 +24,14 @@ vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') return { useTranslation: () => ({ - t: withSelectorKey((key: string, options?: { ns?: string; num?: number }) => - key === 'marketplace.pluginsResult' && options?.ns === 'plugin' - ? `${options.num} plugins found` - : options?.ns - ? `${options.ns}.${key}` - : key, - ), + t: withSelectorKey((key: string, options?: Record<string, unknown>) => { + if (key === 'marketplace.pluginsResult') return `${options?.num} plugins found` + return key + }), }), } }) -vi.mock('../../state', () => ({ - useMarketplaceData: () => mockMarketplaceData, -})) - vi.mock('@/app/components/base/loading', () => ({ default: ({ className }: { className?: string }) => ( <div data-testid="loading" className={className}> @@ -51,6 +50,17 @@ vi.mock('../index', () => ({ ), })) +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +// ListWrapper reads the raw `q` through nuqs for its analytics flush, so the +// tree needs an adapter even though the data hook itself is mocked. +const renderListWrapper = (ui: ReactNode) => { + const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams: '' }) + return render(<NuqsWrapper>{ui}</NuqsWrapper>) +} + describe('ListWrapper', () => { beforeEach(() => { vi.clearAllMocks() @@ -59,6 +69,8 @@ describe('ListWrapper', () => { mockMarketplaceData.marketplaceCollections = [] mockMarketplaceData.marketplaceCollectionPluginsMap = {} mockMarketplaceData.isLoading = false + mockMarketplaceData.isRefreshing = false + mockMarketplaceData.isError = false mockMarketplaceData.isFetchingNextPage = false mockMarketplaceData.page = 1 }) @@ -67,20 +79,35 @@ describe('ListWrapper', () => { mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] mockMarketplaceData.pluginsTotal = 1 - render(<ListWrapper />) + renderListWrapper(<ListWrapper />) expect(screen.getByText('1 plugins found')).toBeInTheDocument() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() }) - it('shows centered loading only on initial loading page', () => { + it('shows centered loading on a cold start', () => { mockMarketplaceData.isLoading = true mockMarketplaceData.page = 1 - render(<ListWrapper />) + renderListWrapper(<ListWrapper />) expect(screen.getByTestId('loading')).toBeInTheDocument() - expect(screen.queryByTestId('list')).not.toBeInTheDocument() + }) + + // The reported "jitter": every debounced keystroke used to unmount the grid + // behind a centre-absolute spinner, collapsing the container height and + // jumping the scroll position. + it('keeps the result grid mounted while a superseded query is in flight', () => { + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + mockMarketplaceData.isRefreshing = true + + renderListWrapper(<ListWrapper />) + + const list = screen.getByTestId('list') + expect(list).toBeInTheDocument() + expect(list.parentElement).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() }) it('renders list when loading additional pages', () => { @@ -88,7 +115,7 @@ describe('ListWrapper', () => { mockMarketplaceData.page = 2 mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] - render(<ListWrapper showInstallButton />) + renderListWrapper(<ListWrapper showInstallButton />) expect(screen.getByTestId('list')).toBeInTheDocument() }) @@ -97,8 +124,34 @@ describe('ListWrapper', () => { mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] mockMarketplaceData.isFetchingNextPage = true - render(<ListWrapper />) + renderListWrapper(<ListWrapper />) expect(screen.getAllByTestId('loading')).toHaveLength(1) }) + + it('keeps the supplied layout constraint while category results are loading', () => { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 + + const { container } = renderListWrapper(<ListWrapper className="catalog-content-min-height" />) + + expect(container.firstElementChild).toHaveClass('catalog-content-min-height') + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + // A failed search used to arrive as a successful empty page and render as + // "no plugins found", with nothing to retry. + it('offers a retry when the search failed with nothing to show', async () => { + const user = userEvent.setup() + mockMarketplaceData.isError = true + mockMarketplaceData.plugins = [] + + renderListWrapper(<ListWrapper />) + + expect(screen.queryByTestId('list')).not.toBeInTheDocument() + expect(screen.getByText('marketplace.loadError')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'operation.retry' })) + expect(mockMarketplaceData.refetch).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 0e3fcd0c186..a6e989f4beb 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,61 +1,63 @@ 'use client' import type { Plugin } from '@/app/components/plugins/types' -import { Button, buttonVariants } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' +import { Button } from '@langgenius/dify-ui/button' import { useBoolean } from 'ahooks' -import { useTheme } from 'next-themes' import * as React from 'react' import { useMemo } from 'react' -import { useLocale, useTranslation } from '#i18n' +import { useTranslation } from '#i18n' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import { useTags } from '@/app/components/plugins/hooks' import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Link from '@/next/link' -import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' +import { trackMarketplaceSiteCardClick } from '@/utils/marketplace-site-track' +import MarketplaceDetailDialog from '../detail-dialog' +import { getPluginDetailLinkInMarketplace } from '../utils' type CardWrapperProps = { plugin: Plugin showInstallButton?: boolean isInstalled?: boolean linkToMarketplaceDetail?: boolean + section?: string } const CardWrapperComponent = ({ plugin, showInstallButton, isInstalled = false, linkToMarketplaceDetail = false, + section = 'list', }: CardWrapperProps) => { const { t } = useTranslation() - const { theme } = useTheme() const [ isShowInstallFromMarketplace, { setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace }, ] = useBoolean(false) + const [ + isShowMarketplaceDetail, + { setTrue: showMarketplaceDetail, setFalse: hideMarketplaceDetail }, + ] = useBoolean(false) const { canInstallPlugin } = useOptionalPluginInstallPermission() - const locale = useLocale() const { getTagLabel } = useTags() - // Memoize marketplace link params to prevent unnecessary re-renders - const marketplaceLinkParams = useMemo( - () => ({ - language: locale, - theme, - }), - [locale, theme], - ) - // Memoize tag labels to prevent recreating array on every render const tagLabels = useMemo( () => plugin.tags.map((tag) => getTagLabel(tag.name)), [plugin.tags, getTagLabel], ) + const handleMarketplaceDetailOpenChange = (open: boolean) => { + if (open) showMarketplaceDetail() + else hideMarketplaceDetail() + } const showInstallAction = !!showInstallButton && canInstallPlugin if (showInstallAction) { return ( - <div className="group relative cursor-pointer rounded-xl"> + <div + className="group relative cursor-pointer rounded-xl" + data-marketplace-card={plugin.plugin_id} + > <Card key={plugin.name} payload={plugin} @@ -79,16 +81,20 @@ const CardWrapperComponent = ({ ? t(($) => $['task.installed'], { ns: 'plugin' }) : t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} </Button> - <a - href={getPluginLinkInMarketplace(plugin, marketplaceLinkParams)} - target="_blank" - rel="noopener noreferrer" - className={cn(buttonVariants(), 'min-w-0 flex-1 shadow-xs backdrop-blur-[5px]')} + <Button + className="min-w-0 flex-1 shadow-xs backdrop-blur-[5px]" + onClick={showMarketplaceDetail} > {t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-up-line size-4" /> - </a> + </Button> </div> + <MarketplaceDetailDialog + isInstalled={isInstalled} + open={isShowMarketplaceDetail} + plugin={plugin} + onInstall={showInstallFromMarketplace} + onOpenChange={handleMarketplaceDetailOpenChange} + /> {isShowInstallFromMarketplace && ( <InstallFromMarketplace manifest={plugin} @@ -102,7 +108,7 @@ const CardWrapperComponent = ({ } const card = ( - <div className="group relative rounded-xl"> + <div className="group relative rounded-xl" data-marketplace-card={plugin.plugin_id}> <Card key={plugin.name} payload={plugin} @@ -120,10 +126,19 @@ const CardWrapperComponent = ({ if (!linkToMarketplaceDetail) return card + const itemId = `${plugin.org}/${plugin.name}` + return ( <Link href={getPluginDetailLinkInMarketplace(plugin)} className="block rounded-xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + onClick={() => { + trackMarketplaceSiteCardClick({ + itemId, + itemType: 'plugin', + section, + }) + }} > {card} </Link> diff --git a/web/app/components/plugins/marketplace/list/carousel.module.css b/web/app/components/plugins/marketplace/list/carousel.module.css new file mode 100644 index 00000000000..23978e585f4 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/carousel.module.css @@ -0,0 +1,5 @@ +@media (max-width: 879px) { + :global([data-marketplace-standalone]) .pagination { + display: none; + } +} diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index 3b94c907295..e8cdc527a18 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -1,38 +1,45 @@ 'use client' /* oxlint-disable eslint-react/set-state-in-effect */ +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import Autoplay from 'embla-carousel-autoplay' import useEmblaCarousel from 'embla-carousel-react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import styles from './carousel.module.css' +import { CAROUSEL_PAGE_CLASS } from './collection-constants' -type CarouselApi = ReturnType<typeof useEmblaCarousel>[1] +export type CarouselPage = { + id: string + content: ReactNode +} type CarouselProps = { - children: React.ReactNode + pages: CarouselPage[] + ariaLabel?: string className?: string showNavigation?: boolean showPagination?: boolean autoPlay?: boolean autoPlayInterval?: number + deferMountPages?: boolean + pauseWhenOffscreen?: boolean } type NavButtonProps = { - direction: 'left' | 'right' - disabled: boolean + label: string onClick: () => void iconClassName: string } -const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonProps) => ( +const NavButton = ({ label, onClick, iconClassName }: NavButtonProps) => ( <button - className={cn( - 'flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover', - disabled && 'cursor-not-allowed opacity-50 hover:bg-components-button-secondary-bg', - )} + type="button" + className="flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover" onClick={onClick} - disabled={disabled} - aria-label={`Scroll ${direction}`} + aria-label={label} > <span aria-hidden @@ -42,22 +49,23 @@ const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonPro ) type CarouselControlsProps = { - api: CarouselApi showPagination: boolean selectedIndex: number scrollNext: () => void scrollPrev: () => void scrollSnaps: number[] + scrollTo: (index: number) => void } const CarouselControls = ({ - api, showPagination, selectedIndex, scrollNext, scrollPrev, scrollSnaps, + scrollTo, }: CarouselControlsProps) => { + const { t } = useTranslation() const paginationItems = scrollSnaps.map((snap, index) => ({ id: `${snap}-${index}`, snap, @@ -69,7 +77,7 @@ const CarouselControls = ({ return ( <div className="absolute -top-10 right-0 flex items-center gap-3"> {showPagination && ( - <div className="flex items-center gap-1"> + <div className={cn(styles.pagination, 'flex items-center gap-1')}> {paginationItems.map((item, index) => ( <button key={item.id} @@ -79,22 +87,23 @@ const CarouselControls = ({ ? 'w-4 bg-components-button-primary-bg' : 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover', )} - onClick={() => api?.scrollTo(index)} - aria-label={`Go to page ${index + 1}`} + onClick={() => scrollTo(index)} + aria-label={t(($) => $['marketplace.carousel.goToPage'], { + ns: 'plugin', + page: index + 1, + })} /> ))} </div> )} <div className="flex items-center gap-1"> <NavButton - direction="left" - disabled={totalPages <= 1} + label={t(($) => $['marketplace.carousel.scrollPrevious'], { ns: 'plugin' })} onClick={scrollPrev} iconClassName="i-ri-arrow-left-s-line" /> <NavButton - direction="right" - disabled={totalPages <= 1} + label={t(($) => $['marketplace.carousel.scrollNext'], { ns: 'plugin' })} onClick={scrollNext} iconClassName="i-ri-arrow-right-s-line" /> @@ -103,44 +112,106 @@ const CarouselControls = ({ ) } +const normalizePageIndex = (index: number, pageCount: number) => + ((index % pageCount) + pageCount) % pageCount + +const getPageWindowIds = (pages: CarouselPage[], centerIndex: number) => { + if (!pages.length) return [] + + return [-1, 0, 1].map( + (offset) => pages[normalizePageIndex(centerIndex + offset, pages.length)]!.id, + ) +} + const Carousel = ({ - children, + pages, + ariaLabel, className, showNavigation = true, showPagination = true, autoPlay = false, autoPlayInterval = 5000, + deferMountPages = false, + pauseWhenOffscreen = false, }: CarouselProps) => { - const plugins = useMemo(() => { - if (!autoPlay) return [] + const carouselRootRef = useRef<HTMLDivElement>(null) + const [isFocusPaused, setIsFocusPaused] = useState(false) + // Tracked independently of pauseWhenOffscreen so every autoplay path honors + // prefers-reduced-motion, including the eagerly-playing first collection. + const [isReducedMotion, setIsReducedMotion] = useState( + () => + typeof window !== 'undefined' && + (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false), + ) + const autoplay = useMemo(() => { + if (!autoPlay) return undefined - return [ - Autoplay({ - delay: autoPlayInterval, - stopOnInteraction: false, - stopOnMouseEnter: true, - }), - ] - }, [autoPlay, autoPlayInterval]) + return Autoplay({ + delay: autoPlayInterval, + playOnInit: !pauseWhenOffscreen, + stopOnInteraction: false, + stopOnMouseEnter: !pauseWhenOffscreen, + }) + }, [autoPlay, autoPlayInterval, pauseWhenOffscreen]) + const plugins = useMemo(() => (autoplay ? [autoplay] : []), [autoplay]) const [carouselRef, api] = useEmblaCarousel( { align: 'start', containScroll: 'trimSnaps', loop: true }, plugins, ) const [selectedIndex, setSelectedIndex] = useState(0) const [scrollSnaps, setScrollSnaps] = useState<number[]>([]) + const [mountedPageIds, setMountedPageIds] = useState( + () => new Set(deferMountPages ? getPageWindowIds(pages, 0) : pages.map((page) => page.id)), + ) + + const mountPageWindow = useCallback( + (centerIndex: number) => { + if (!deferMountPages || !pages.length) return + + const pageIds = getPageWindowIds(pages, centerIndex) + setMountedPageIds((currentPageIds) => { + if (pageIds.every((pageId) => currentPageIds.has(pageId))) return currentPageIds + + return new Set([...currentPageIds, ...pageIds]) + }) + }, + [deferMountPages, pages], + ) + + const scheduleScroll = useCallback((scroll: () => void) => { + window.requestAnimationFrame(scroll) + }, []) + + const scrollTo = useCallback( + (index: number) => { + mountPageWindow(index) + scheduleScroll(() => api?.scrollTo(index)) + }, + [api, mountPageWindow, scheduleScroll], + ) const scrollPrev = useCallback(() => { - api?.scrollPrev() - }, [api]) + mountPageWindow(selectedIndex - 1) + scheduleScroll(() => api?.scrollPrev()) + }, [api, mountPageWindow, scheduleScroll, selectedIndex]) const scrollNext = useCallback(() => { - api?.scrollNext() - }, [api]) + mountPageWindow(selectedIndex + 1) + scheduleScroll(() => api?.scrollNext()) + }, [api, mountPageWindow, scheduleScroll, selectedIndex]) + + useEffect(() => { + if (!deferMountPages) return + + mountPageWindow(selectedIndex) + }, [deferMountPages, mountPageWindow, pages, selectedIndex]) useEffect(() => { if (!api) return const handleSelect = () => { - setSelectedIndex(api.selectedScrollSnap()) + const nextSelectedIndex = api.selectedScrollSnap() + setSelectedIndex(nextSelectedIndex) setScrollSnaps(api.scrollSnapList()) + mountPageWindow(nextSelectedIndex) } handleSelect() @@ -151,23 +222,167 @@ const Carousel = ({ api.off('reInit', handleSelect) api.off('select', handleSelect) } - }, [api]) + }, [api, mountPageWindow]) + + useEffect(() => { + if (!autoplay) return + + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + // Once keyboard or assistive-technology focus enters the carousel + // (including its controls), rotation stays stopped so the content no + // longer changes underneath the user. + const handleFocusIn = () => setIsFocusPaused(true) + + carouselRoot.addEventListener('focusin', handleFocusIn) + return () => carouselRoot.removeEventListener('focusin', handleFocusIn) + }, [autoplay]) + + // The viewport-managed effect below tracks reduced motion itself; this + // effect covers the eager autoplay path (pauseWhenOffscreen=false), which + // previously ignored the preference entirely. + useEffect(() => { + if (!autoPlay || pauseWhenOffscreen) return + + const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)') + if (!reducedMotionQuery) return + + const syncReducedMotion = () => setIsReducedMotion(reducedMotionQuery.matches) + + syncReducedMotion() + reducedMotionQuery.addEventListener('change', syncReducedMotion) + return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion) + }, [autoPlay, pauseWhenOffscreen]) + + useEffect(() => { + if (!autoplay || !api || pauseWhenOffscreen) return + + // Autoplay skips its own setup on single-page carousels, so play() would + // crash inside the plugin; a lone page has nothing to rotate through anyway. + if (scrollSnaps.length <= 1 || isFocusPaused || isReducedMotion) autoplay.stop() + else autoplay.play() + }, [api, autoplay, isFocusPaused, isReducedMotion, pauseWhenOffscreen, scrollSnaps]) + + useEffect(() => { + if (!pauseWhenOffscreen || !autoplay || !api) return + + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + let isInViewport = false + let isHovered = false + let isDocumentVisible = document.visibilityState === 'visible' + const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)') + let isReducedMotion = reducedMotionQuery?.matches ?? false + + const syncAutoplay = () => { + const hasMultiplePages = api.scrollSnapList().length > 1 + + if ( + hasMultiplePages && + isInViewport && + isDocumentVisible && + !isReducedMotion && + !isHovered && + !isFocusPaused + ) + autoplay.play() + else autoplay.stop() + } + const handleVisibilityChange = () => { + isDocumentVisible = document.visibilityState === 'visible' + syncAutoplay() + } + const handleReducedMotionChange = () => { + isReducedMotion = reducedMotionQuery?.matches ?? false + syncAutoplay() + } + const handleMouseEnter = () => { + isHovered = true + syncAutoplay() + } + const handleMouseLeave = () => { + isHovered = false + syncAutoplay() + } + + const observer = + typeof IntersectionObserver === 'undefined' + ? undefined + : new IntersectionObserver( + ([entry]) => { + isInViewport = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25 + syncAutoplay() + }, + { + root: document.getElementById(MARKETPLACE_CONTAINER_ID), + threshold: 0.25, + }, + ) + + if (observer) observer.observe(carouselRoot) + else isInViewport = true + + document.addEventListener('visibilitychange', handleVisibilityChange) + reducedMotionQuery?.addEventListener('change', handleReducedMotionChange) + carouselRoot.addEventListener('mouseenter', handleMouseEnter) + carouselRoot.addEventListener('mouseleave', handleMouseLeave) + syncAutoplay() + + return () => { + observer?.disconnect() + document.removeEventListener('visibilitychange', handleVisibilityChange) + reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange) + carouselRoot.removeEventListener('mouseenter', handleMouseEnter) + carouselRoot.removeEventListener('mouseleave', handleMouseLeave) + autoplay.stop() + } + }, [api, autoplay, isFocusPaused, pauseWhenOffscreen]) return ( - <div className={cn('relative', className)} role="region" aria-roledescription="carousel"> + <div + ref={carouselRootRef} + className={cn('relative', className)} + role="region" + aria-roledescription="carousel" + aria-label={ariaLabel} + > {showNavigation && ( <CarouselControls - api={api} showPagination={showPagination} selectedIndex={selectedIndex} scrollNext={scrollNext} scrollPrev={scrollPrev} scrollSnaps={scrollSnaps} + scrollTo={scrollTo} /> )} <div ref={carouselRef} className="overflow-hidden rounded-[inherit]"> <div className="flex" style={{ columnGap: '12px' }}> - {children} + {pages.map((page, index) => { + const isMounted = !deferMountPages || mountedPageIds.has(page.id) + const isCurrent = index === selectedIndex + + return ( + <div + key={page.id} + role="group" + aria-roledescription="slide" + aria-label={`${index + 1} / ${pages.length}`} + // Off-screen pages stay mounted for Embla, but must not be + // reachable through the tab order or the accessibility tree. + aria-hidden={!isCurrent} + inert={!isCurrent} + className={CAROUSEL_PAGE_CLASS} + data-carousel-page={page.id} + data-carousel-page-mounted={isMounted ? 'true' : 'false'} + style={{ scrollSnapAlign: 'start' }} + > + {isMounted ? page.content : null} + </div> + ) + })} </div> </div> </div> diff --git a/web/app/components/plugins/marketplace/list/collection-constants.ts b/web/app/components/plugins/marketplace/list/collection-constants.ts index 842d9acb785..c11acb6cd0f 100644 --- a/web/app/components/plugins/marketplace/list/collection-constants.ts +++ b/web/app/components/plugins/marketplace/list/collection-constants.ts @@ -1,5 +1,15 @@ export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' +export const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk' + +// Collections whose header shows the "Become a Partner" call to action, as +// named by the Marketplace API for the plugin and template catalogs. +export const PARTNER_COLLECTION_NAMES = new Set([ + 'partners', + 'partner-template', + 'Partner Template', +]) + export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0' export const CAROUSEL_PAGE_SIZE = { diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 6d6c227b56e..f6d94dddc0c 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -20,6 +20,8 @@ type ListProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null emptyClassName?: string onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void + deferOffscreenCollections?: boolean + cardSection?: string } const List = ({ marketplaceCollections, @@ -31,6 +33,8 @@ const List = ({ cardRender, emptyClassName, onCollectionMoreClick, + deferOffscreenCollections, + cardSection = 'list', }: ListProps) => { const { canInstallPlugin } = useOptionalPluginInstallPermission() const pluginIds = useMemo(() => { @@ -69,6 +73,7 @@ const List = ({ cardRender={cardRender} onCollectionMoreClick={onCollectionMoreClick} installedPluginIds={installedPluginIds} + deferOffscreenCollections={deferOffscreenCollections} /> )} {plugins && !!plugins.length && ( @@ -83,6 +88,7 @@ const List = ({ showInstallButton={showInstallButton} isInstalled={installedPluginIds.has(plugin.plugin_id)} linkToMarketplaceDetail={linkToMarketplaceDetail} + section={cardSection} /> ) })} diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 3fcdd727251..a5c8cb12159 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -3,33 +3,22 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '@dify/contracts/marketplace' import type { Plugin } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useLocale, useTranslation } from '#i18n' import { getLanguage } from '@/i18n-config/language' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' import { useMarketplaceMoreClick } from '../atoms' +import { MARKETPLACE_CONTAINER_ID } from '../constants' import { buildCarouselPages } from '../utils' import CardWrapper from './card-wrapper' import Carousel from './carousel' -import { - CAROUSEL_BREAKPOINTS, - CAROUSEL_PAGE_CLASS, - CAROUSEL_PAGE_SIZE, - GRID_CLASS, -} from './collection-constants' +import { BECOME_PARTNER_URL, GRID_CLASS, PARTNER_COLLECTION_NAMES } from './collection-constants' +import styles from './partner-header.module.css' +import { useCarouselItemsPerPage } from './use-carousel-items-per-page' -const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk' -const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template']) - -const getViewportWidth = () => - typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth - -const getCarouselItemsPerPage = (viewportWidth: number) => { - if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl - if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg - if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm - - return CAROUSEL_PAGE_SIZE.base -} +const COLLECTION_PRELOAD_MARGIN = '320px 0px' +const COLLECTION_INTERSECTION_THRESHOLD = 0.01 +const MAX_PLACEHOLDER_CARDS = 8 type ListWithCollectionProps = { marketplaceCollections: MarketplaceCollection[] @@ -40,6 +29,7 @@ type ListWithCollectionProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void installedPluginIds?: ReadonlySet<string> + deferOffscreenCollections?: boolean } type PluginCardProps = { @@ -48,6 +38,7 @@ type PluginCardProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null isInstalled?: boolean linkToMarketplaceDetail?: boolean + section?: string } const PluginCard = ({ @@ -56,6 +47,7 @@ const PluginCard = ({ cardRender, isInstalled, linkToMarketplaceDetail, + section, }: PluginCardProps) => { if (cardRender) return cardRender(plugin) @@ -65,10 +57,235 @@ const PluginCard = ({ showInstallButton={showInstallButton} isInstalled={isInstalled} linkToMarketplaceDetail={linkToMarketplaceDetail} + section={section} /> ) } +type CollectionSectionProps = { + collection: MarketplaceCollection + plugins: Plugin[] + itemsPerPage: number + showInstallButton?: boolean + linkToMarketplaceDetail?: boolean + cardContainerClassName?: string + cardRender?: (plugin: Plugin) => React.JSX.Element | null + onMoreClick: (searchParams?: SearchParamsFromCollection) => void + installedPluginIds?: ReadonlySet<string> + deferMount: boolean +} + +const CollectionPlaceholder = ({ + cardContainerClassName, + count, +}: { + cardContainerClassName?: string + count: number +}) => ( + <div + aria-hidden + className={cn('mt-2', GRID_CLASS, cardContainerClassName)} + data-marketplace-collection-placeholder + > + {Array.from({ length: count }, (_, index) => ( + <div + key={index} + className="h-[148px] min-w-0 rounded-xl border border-components-panel-border-subtle bg-background-default-subtle" + /> + ))} + </div> +) + +const CollectionSection = ({ + collection, + plugins, + itemsPerPage, + showInstallButton, + linkToMarketplaceDetail, + cardContainerClassName, + cardRender, + onMoreClick, + installedPluginIds, + deferMount, +}: CollectionSectionProps) => { + const { t } = useTranslation() + const locale = useLocale() + const sectionRef = useRef<HTMLDivElement>(null) + const [isMounted, setIsMounted] = useState(!deferMount) + const pages = useMemo(() => buildCarouselPages(plugins, itemsPerPage), [itemsPerPage, plugins]) + const hasMultiplePages = pages.length > 1 + const isPartnersCollection = PARTNER_COLLECTION_NAMES.has(collection.name) + + useEffect(() => { + if (!deferMount || isMounted) return + + const section = sectionRef.current + if (!section) return + + if (typeof IntersectionObserver === 'undefined') { + // oxlint-disable-next-line eslint-react/set-state-in-effect -- This is the hydration fallback for browsers without IntersectionObserver. + setIsMounted(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return + + setIsMounted(true) + observer.disconnect() + }, + { + root: document.getElementById(MARKETPLACE_CONTAINER_ID), + rootMargin: COLLECTION_PRELOAD_MARGIN, + threshold: COLLECTION_INTERSECTION_THRESHOLD, + }, + ) + + observer.observe(section) + + return () => observer.disconnect() + }, [deferMount, isMounted]) + + const carouselPages = useMemo( + () => + pages.map((pageItems, pageIndex) => ({ + id: `${collection.name}-${itemsPerPage}-${pageIndex}`, + content: ( + <div className={cn(GRID_CLASS, cardContainerClassName)}> + {pageItems.map((plugin) => ( + <div key={plugin.plugin_id} className="min-w-0 *:w-full"> + <PluginCard + plugin={plugin} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} + section={collection.name} + /> + </div> + ))} + </div> + ), + })), + [ + cardContainerClassName, + cardRender, + collection.name, + installedPluginIds, + itemsPerPage, + linkToMarketplaceDetail, + pages, + showInstallButton, + ], + ) + + return ( + <div ref={sectionRef} className="py-3" data-marketplace-collection={collection.name}> + <div className="flex items-end justify-between"> + <div + className={cn( + isPartnersCollection && styles.partnerHeader, + isPartnersCollection && hasMultiplePages && styles.partnerHeaderWithNavigation, + )} + > + <div + className={cn( + 'title-xl-semi-bold text-text-primary', + isPartnersCollection && styles.partnerTitle, + )} + > + {collection.label[getLanguage(locale)]} + </div> + <div + className={cn( + 'flex items-center gap-x-2 system-xs-regular text-text-tertiary', + isPartnersCollection && styles.partnerMetadata, + )} + > + {isPartnersCollection ? ( + <span className={styles.partnerDescription}> + {collection.description[getLanguage(locale)]} + </span> + ) : ( + collection.description[getLanguage(locale)] + )} + {isPartnersCollection && ( + <> + <span className={cn(styles.partnerSeparator, 'text-divider-regular')}>|</span> + <a + href={BECOME_PARTNER_URL} + target="_blank" + rel="noopener noreferrer" + className={cn( + styles.partnerAction, + 'flex items-center gap-x-0.5 text-text-accent hover:underline', + )} + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'Become a Partner', + }) + }} + > + <span className={styles.partnerActionLabel}> + {t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })} + </span> + <span + aria-hidden + className={cn(styles.partnerActionIcon, 'i-ri-external-link-line size-3')} + /> + </a> + </> + )} + </div> + </div> + {collection.searchable && !hasMultiplePages && ( + <button + type="button" + className="flex cursor-pointer items-center system-xs-medium text-text-accent" + onClick={() => onMoreClick(collection.search_params)} + > + {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </button> + )} + </div> + {!isMounted ? ( + <CollectionPlaceholder + cardContainerClassName={cardContainerClassName} + count={Math.min(plugins.length, itemsPerPage, MAX_PLACEHOLDER_CARDS)} + /> + ) : hasMultiplePages ? ( + <Carousel + pages={carouselPages} + ariaLabel={collection.label[getLanguage(locale)]} + className="mt-2" + showNavigation + showPagination + autoPlay={isPartnersCollection} + autoPlayInterval={5000} + deferMountPages={deferMount} + pauseWhenOffscreen={deferMount} + /> + ) : ( + <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> + {plugins.map((plugin) => ( + <PluginCard + key={plugin.plugin_id} + plugin={plugin} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} + section={collection.name} + /> + ))} + </div> + )} + </div> + ) +} + const ListWithCollection = ({ marketplaceCollections, marketplaceCollectionPluginsMap, @@ -78,118 +295,33 @@ const ListWithCollection = ({ cardRender, onCollectionMoreClick, installedPluginIds, + deferOffscreenCollections = false, }: ListWithCollectionProps) => { - const { t } = useTranslation() - const locale = useLocale() const defaultOnMoreClick = useMarketplaceMoreClick() const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick - const [viewportWidth, setViewportWidth] = useState(getViewportWidth) - const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth]) + const itemsPerPage = useCarouselItemsPerPage() - useEffect(() => { - const handleResize = () => setViewportWidth(window.innerWidth) - - window.addEventListener('resize', handleResize) - - return () => window.removeEventListener('resize', handleResize) - }, []) - - return ( - <> - {marketplaceCollections - .filter((collection) => { - return marketplaceCollectionPluginsMap[collection.name]?.length - }) - .map((collection) => { - const plugins = marketplaceCollectionPluginsMap[collection.name]! - const pages = buildCarouselPages(plugins, itemsPerPage) - const hasMultiplePages = pages.length > 1 - const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name) - - return ( - <div key={collection.name} className="py-3"> - <div className="flex items-end justify-between"> - <div> - <div className="title-xl-semi-bold text-text-primary"> - {collection.label[getLanguage(locale)]} - </div> - <div className="flex items-center gap-x-2 system-xs-regular text-text-tertiary"> - {collection.description[getLanguage(locale)]} - {isPartnersCollection && ( - <> - <span className="text-divider-regular">|</span> - <a - href={BECOME_PARTNER_URL} - target="_blank" - rel="noopener noreferrer" - className="flex items-center gap-x-0.5 text-text-accent hover:underline" - > - <span>{t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })}</span> - <span aria-hidden className="i-ri-external-link-line size-3" /> - </a> - </> - )} - </div> - </div> - {collection.searchable && !hasMultiplePages && ( - <div - className="flex cursor-pointer items-center system-xs-medium text-text-accent" - onClick={() => handleMoreClick(collection.search_params)} - > - {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> - </div> - )} - </div> - {hasMultiplePages ? ( - <Carousel - className="mt-2" - showNavigation - showPagination - autoPlay={isPartnersCollection} - autoPlayInterval={5000} - > - {pages.map((pageItems) => ( - <div - key={pageItems.map((plugin) => plugin.plugin_id).join('-')} - className={CAROUSEL_PAGE_CLASS} - style={{ scrollSnapAlign: 'start' }} - > - <div className={cn(GRID_CLASS, cardContainerClassName)}> - {pageItems.map((plugin) => ( - <div key={plugin.plugin_id} className="min-w-0 *:w-full"> - <PluginCard - plugin={plugin} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - cardRender={cardRender} - isInstalled={installedPluginIds?.has(plugin.plugin_id)} - /> - </div> - ))} - </div> - </div> - ))} - </Carousel> - ) : ( - <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> - {plugins.map((plugin) => ( - <PluginCard - key={plugin.plugin_id} - plugin={plugin} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - cardRender={cardRender} - isInstalled={installedPluginIds?.has(plugin.plugin_id)} - /> - ))} - </div> - )} - </div> - ) - })} - </> - ) + return marketplaceCollections + .filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length) + .map((collection, index) => ( + <CollectionSection + key={collection.name} + collection={collection} + plugins={marketplaceCollectionPluginsMap[collection.name]!} + itemsPerPage={itemsPerPage} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardContainerClassName={cardContainerClassName} + cardRender={cardRender} + onMoreClick={handleMoreClick} + installedPluginIds={installedPluginIds} + // The first collection is above-the-fold content: it must render its + // cards in the server-rendered HTML so a direct visit shows real + // content without waiting for client-side JS. Only collections below + // it defer to the IntersectionObserver. + deferMount={deferOffscreenCollections && index > 0} + /> + )) } export default ListWithCollection diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 63a1debe9db..61942e562cd 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,15 +1,34 @@ 'use client' +import type { ActivePluginType } from '../constants' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef } from 'react' import { useTranslation } from '#i18n' import Loading from '@/app/components/base/loading' +import { + flushMarketplaceSiteFilter, + flushMarketplaceSiteSearch, + markMarketplaceSiteSearch, +} from '@/utils/marketplace-site-track' +import { useSearchPluginText } from '../atoms' import SortDropdown from '../sort-dropdown' import { useMarketplaceData } from '../state' import List from './index' type ListWrapperProps = { + activePluginType?: ActivePluginType + className?: string + deferOffscreenCollections?: boolean showInstallButton?: boolean linkToMarketplaceDetail?: boolean } -const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => { +const ListWrapper = ({ + activePluginType, + className, + deferOffscreenCollections, + showInstallButton, + linkToMarketplaceDetail, +}: ListWrapperProps) => { const { t } = useTranslation() const { @@ -18,17 +37,50 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper marketplaceCollections, marketplaceCollectionPluginsMap, isLoading, + isRefreshing, + isError, + refetch, isFetchingNextPage, page, - } = useMarketplaceData() + } = useMarketplaceData(activePluginType) + const [searchPluginText] = useSearchPluginText() + const previousSearchRef = useRef(searchPluginText) + const isFirstSearchRender = useRef(true) + + useEffect(() => { + if (isFirstSearchRender.current) { + isFirstSearchRender.current = false + previousSearchRef.current = searchPluginText + return + } + + if (searchPluginText && searchPluginText !== previousSearchRef.current) + markMarketplaceSiteSearch(searchPluginText) + + previousSearchRef.current = searchPluginText + }, [searchPluginText]) + + useEffect(() => { + if (isLoading || isError || pluginsTotal === undefined) return + + flushMarketplaceSiteSearch(pluginsTotal) + flushMarketplaceSiteFilter(pluginsTotal) + }, [isLoading, isError, pluginsTotal]) return ( <div style={{ + // The first live-search response inserts the result summary above the + // existing grid. Keep Chromium from treating a card in this dynamic + // region as the scroll anchor and compensating by moving the page. + overflowAnchor: 'none', scrollbarGutter: 'stable', paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))', }} - className="relative flex grow flex-col bg-background-default-subtle px-8 py-2" + className={cn( + 'relative flex grow flex-col bg-background-default-subtle px-8 py-2', + className, + )} > <div className="flex w-full grow flex-col"> {plugins && ( @@ -40,14 +92,34 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper <SortDropdown /> </div> )} - {(!isLoading || page > 1) && ( - <List - marketplaceCollections={marketplaceCollections || []} - marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} - plugins={plugins} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - /> + {isError && !plugins?.length ? ( + <div className="flex min-h-60 flex-col items-center justify-center gap-3 text-sm text-text-tertiary"> + <span>{t(($) => $['marketplace.loadError'], { ns: 'plugin' })}</span> + <Button size="small" variant="secondary" onClick={() => void refetch()}> + {t(($) => $['operation.retry'], { ns: 'common' })} + </Button> + </div> + ) : ( + // Rendered even while a superseded query is in flight: unmounting + // the grid collapsed the container and jumped the scroll position + // on every search keystroke. `isRefreshing` dims it instead. + <div + className={cn( + 'flex grow flex-col transition-opacity duration-150', + isRefreshing && 'opacity-60', + )} + aria-busy={isRefreshing || undefined} + > + <List + marketplaceCollections={marketplaceCollections || []} + marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} + plugins={plugins} + deferOffscreenCollections={deferOffscreenCollections} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardSection={searchPluginText ? 'search' : 'list'} + /> + </div> )} </div> {isLoading && page === 1 && ( diff --git a/web/app/components/plugins/marketplace/list/partner-header.module.css b/web/app/components/plugins/marketplace/list/partner-header.module.css new file mode 100644 index 00000000000..3124c729091 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/partner-header.module.css @@ -0,0 +1,52 @@ +@media (max-width: 879px) { + :global([data-marketplace-standalone]) .partnerHeader { + box-sizing: border-box; + display: grid; + width: 100%; + grid-template-areas: + 'title action' + 'description description'; + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + column-gap: 12px; + } + + :global([data-marketplace-standalone]) .partnerHeaderWithNavigation { + padding-right: 80px; + } + + :global([data-marketplace-standalone]) .partnerTitle { + grid-area: title; + } + + :global([data-marketplace-standalone]) .partnerMetadata { + display: contents; + } + + :global([data-marketplace-standalone]) .partnerDescription { + grid-area: description; + min-width: 0; + } + + :global([data-marketplace-standalone]) .partnerSeparator { + display: none; + } + + :global([data-marketplace-standalone]) .partnerAction { + grid-area: action; + justify-self: start; + min-width: 0; + max-width: 100%; + white-space: nowrap; + } + + :global([data-marketplace-standalone]) .partnerActionLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + :global([data-marketplace-standalone]) .partnerActionIcon { + flex-shrink: 0; + } +} diff --git a/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts new file mode 100644 index 00000000000..a17b3f1bee4 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts @@ -0,0 +1,37 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE } from './collection-constants' + +const subscribeToViewport = (onStoreChange: () => void) => { + globalThis.window?.addEventListener('resize', onStoreChange) + + return () => globalThis.window?.removeEventListener('resize', onStoreChange) +} + +const getViewportWidth = () => globalThis.window?.innerWidth ?? CAROUSEL_BREAKPOINTS.xl +const getServerViewportWidth = () => CAROUSEL_BREAKPOINTS.xl + +function getCarouselItemsPerPage(viewportWidth: number) { + if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl + if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg + if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm + + return CAROUSEL_PAGE_SIZE.base +} + +/** + * Viewport-derived carousel page size. useSyncExternalStore keeps the + * hydration render on the server snapshot (xl) and applies the real viewport + * in a follow-up render, so narrow viewports do not trigger a hydration + * mismatch against the server-rendered markup. + */ +export function useCarouselItemsPerPage() { + const viewportWidth = useSyncExternalStore( + subscribeToViewport, + getViewportWidth, + getServerViewportWidth, + ) + + return getCarouselItemsPerPage(viewportWidth) +} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.module.css b/web/app/components/plugins/marketplace/plugin-type-switch.module.css new file mode 100644 index 00000000000..50f0086da79 --- /dev/null +++ b/web/app/components/plugins/marketplace/plugin-type-switch.module.css @@ -0,0 +1,25 @@ +.homeItem { + transition: + color 150ms ease, + background-color 150ms ease; +} + +.homeItem:hover { + color: var(--color-text-secondary); + background-color: var(--color-state-base-hover); +} + +.homeItemActive { + color: var(--color-saas-dify-blue-inverted); + background-color: var(--color-background-interaction-from-bg-2); +} + +.homeItemActive:hover { + background-color: var(--color-state-base-hover); +} + +@media (prefers-reduced-motion: reduce) { + .homeItem { + transition: none; + } +} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 6c3d6f876a5..823d5542089 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,31 +1,25 @@ 'use client' import type { ActivePluginType } from './constants' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArchive2Line, - RiBrain2Line, - RiDatabase2Line, - RiHammerLine, - RiPuzzle2Line, - RiSpeakAiLine, -} from '@remixicon/react' import { useSetAtom } from 'jotai' import { Fragment } from 'react' import { useTranslation } from '#i18n' -import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import PluginIcon from '@/app/components/base/icons/src/vender/plugin/Plugin' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' import { searchModeAtom, useActivePluginType } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import styles from './plugin-type-switch.module.css' type PluginTypeSwitchProps = { className?: string - variant?: 'default' | 'hero' + variant?: 'default' | 'hero' | 'home' } const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchProps) => { const { t } = useTranslation() const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() const setSearchMode = useSetAtom(searchModeAtom) const isHero = variant === 'hero' + const isHome = variant === 'home' const iconClassName = 'mr-1.5 size-4' const options: Array<{ @@ -38,42 +32,46 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr text: isHero ? t(($) => $['marketplace.allPlugins'], { ns: 'plugin' }) : t(($) => $['category.all'], { ns: 'plugin' }), - icon: isHero ? <PluginIcon className={iconClassName} /> : null, + icon: isHero || isHome ? <PluginIcon className={iconClassName} /> : null, }, { value: PLUGIN_TYPE_SEARCH_MAP.model, text: t(($) => $['category.models'], { ns: 'plugin' }), - icon: <RiBrain2Line className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-brain-2-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.tool, text: t(($) => $['category.tools'], { ns: 'plugin' }), - icon: <RiHammerLine className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-hammer-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.datasource, - text: t(($) => $['category.datasources'], { ns: 'plugin' }), - icon: <RiDatabase2Line className={iconClassName} />, + text: t(($) => $[isHome ? 'categorySingle.datasource' : 'category.datasources'], { + ns: 'plugin', + }), + icon: <span aria-hidden className={cn('i-ri-database-2-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, - text: t(($) => $['category.agents'], { ns: 'plugin' }), - icon: <RiSpeakAiLine className={iconClassName} />, + text: t(($) => $[isHome ? 'categorySingle.agent' : 'category.agents'], { ns: 'plugin' }), + icon: ( + <span + aria-hidden + className={cn('i-custom-vender-integrations-agent-strategy', iconClassName)} + /> + ), }, { value: PLUGIN_TYPE_SEARCH_MAP.trigger, text: t(($) => $['category.triggers'], { ns: 'plugin' }), - icon: <TriggerIcon className={iconClassName} />, + icon: ( + <span aria-hidden className={cn('i-custom-vender-integrations-trigger', iconClassName)} /> + ), }, { value: PLUGIN_TYPE_SEARCH_MAP.extension, text: t(($) => $['category.extensions'], { ns: 'plugin' }), - icon: <RiPuzzle2Line className={iconClassName} />, - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.bundle, - text: t(($) => $['category.bundles'], { ns: 'plugin' }), - icon: <RiArchive2Line className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-puzzle-2-line', iconClassName)} />, }, ] @@ -82,9 +80,15 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr className={cn( isHero ? 'flex shrink-0 items-center gap-1 overflow-x-auto' - : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', + : isHome + ? 'flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto' + : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', className, )} + role="group" + // Labels the filter group itself; "All integrations" is already the + // first option's text and would read as a duplicate. + aria-label={t(($) => $.allCategories, { ns: 'plugin' })} > {options.map((option, index) => { const isActive = activePluginType === option.value @@ -96,17 +100,31 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr aria-pressed={isActive} className={cn( 'flex h-8 cursor-pointer appearance-none items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', - isHero ? 'text-text-primary-on-surface' : 'text-text-tertiary', + isHero + ? 'text-text-primary-on-surface' + : isHome + ? cn('min-w-12 shrink-0 justify-center text-text-tertiary', styles.homeItem) + : 'text-text-tertiary', !isActive && (isHero ? 'hover:bg-white/20' - : 'hover:bg-state-base-hover hover:text-text-secondary'), + : !isHome && 'hover:bg-state-base-hover hover:text-text-secondary'), isActive && (isHero ? 'border-white/95 bg-components-main-nav-nav-button-bg-active text-saas-dify-blue-inverted shadow-md backdrop-blur-[5px]' - : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'), + : isHome + ? styles.homeItemActive + : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'), )} onClick={() => { + if (option.value !== activePluginType) { + markMarketplaceSiteFilter({ + filter_type: 'type_tab', + selection_mode: 'single', + filter_value: option.value, + selected_values: [option.value], + }) + } handleActivePluginTypeChange(option.value) if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { setSearchMode(null) diff --git a/web/app/components/plugins/marketplace/query-options.ts b/web/app/components/plugins/marketplace/query-options.ts new file mode 100644 index 00000000000..c9a8f5ba8ac --- /dev/null +++ b/web/app/components/plugins/marketplace/query-options.ts @@ -0,0 +1,35 @@ +import type { PluginsSearchParams } from '@dify/contracts/marketplace' +import { infiniteQueryOptions, keepPreviousData } from '@tanstack/react-query' +import { marketplaceQuery } from '@/service/client' +import { getMarketplacePlugins } from './utils' + +export const getMarketplacePluginsInfiniteQueryOptions = ( + queryParams: PluginsSearchParams | undefined, +) => + infiniteQueryOptions({ + queryKey: marketplaceQuery.searchAdvanced.queryKey({ + input: { + body: queryParams ?? { query: '' }, + params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, + }, + }), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.page_size + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + // Hold the previous term's results while the new query is in flight. Without + // this, `data` goes undefined on every keystroke that survives the debounce, + // the grid unmounts, the container collapses, and the scroll position jumps — + // the "jitter" the Marketplace search is reported for. Consumers show a + // quiet pending state off `isPlaceholderData` instead. + placeholderData: keepPreviousData, + // Matches the autocomplete queries. Now that the fetcher propagates + // failures, react-query's default of 3 retries would hold isFetching true + // through ~7s of backoff — indistinguishable from a hang. Failing fast and + // offering an explicit Retry is both honest and fewer requests to abort. + retry: false, + }) diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index ff966363686..17195d20efc 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -1,32 +1,24 @@ import type { MarketPlaceInputs, PluginsSearchParams } from '@dify/contracts/marketplace' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { marketplaceQuery } from '@/service/client' -import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' +import { getMarketplacePluginsInfiniteQueryOptions } from './query-options' +import { getMarketplaceCollectionsAndPlugins } from './utils' export function useMarketplaceCollectionsAndPlugins( collectionsParams: MarketPlaceInputs['collections']['query'], + enabled = true, ) { return useQuery({ queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + enabled, + // Matches the plugins query: the shared client default of 3 retries holds + // isFetching true for ~7s of backoff, which the catalog renders as a + // spinner indistinguishable from a hang. + retry: false, }) } export function useMarketplacePlugins(queryParams: PluginsSearchParams | undefined) { - return useInfiniteQuery({ - queryKey: marketplaceQuery.searchAdvanced.queryKey({ - input: { - body: queryParams!, - params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, - }, - }), - queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), - getNextPageParam: (lastPage) => { - const nextPage = lastPage.page + 1 - const loaded = lastPage.page * lastPage.page_size - return loaded < (lastPage.total || 0) ? nextPage : undefined - }, - initialPageParam: 1, - enabled: queryParams !== undefined, - }) + return useInfiniteQuery(getMarketplacePluginsInfiniteQueryOptions(queryParams)) } diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx index 0424f4396ee..e888aac2653 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx @@ -19,7 +19,7 @@ vi.mock('../index', () => ({ describe('SearchBoxWrapper', () => { it('passes marketplace search state into SearchBox', () => { - render(<SearchBoxWrapper />) + render(<SearchBoxWrapper searchIconName="i-ri-search-line" />) expect(screen.getByTestId('search-box')).toBeInTheDocument() expect(mockSearchBox).toHaveBeenCalledWith( @@ -31,6 +31,7 @@ describe('SearchBoxWrapper', () => { tags: ['agent', 'rag'], onTagsChange: mockHandleFilterPluginTagsChange, placeholder: 'plugin.searchPlugins', + searchIconName: 'i-ri-search-line', usedInMarketplace: true, }), ) diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 2a34bcc2bf5..c117c8cd177 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -14,6 +14,7 @@ type SearchBoxProps = { wrapperClassName?: string inputClassName?: string inputElementClassName?: string + searchIconName?: string searchIconClassName?: string tags: string[] onTagsChange: (tags: string[]) => void @@ -31,6 +32,7 @@ function SearchBox({ wrapperClassName, inputClassName, inputElementClassName, + searchIconName = 'i-ri-search-line', searchIconClassName, tags, onTagsChange, @@ -111,7 +113,7 @@ function SearchBox({ <span aria-hidden className={cn( - 'i-ri-search-line', + searchIconName, 'size-4 text-components-input-text-placeholder', searchIconClassName, )} diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index a1d3c76dfbc..31ed25cfdd0 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -8,6 +8,7 @@ type SearchBoxWrapperProps = { wrapperClassName?: string inputClassName?: string inputElementClassName?: string + searchIconName?: string searchIconClassName?: string placeholder?: string showTags?: boolean @@ -18,6 +19,7 @@ const SearchBoxWrapper = ({ wrapperClassName = 'z-11 mx-auto w-[640px] shrink-0', inputClassName = 'w-full', inputElementClassName, + searchIconName, searchIconClassName, placeholder, showTags = true, @@ -32,6 +34,7 @@ const SearchBoxWrapper = ({ wrapperClassName={wrapperClassName} inputClassName={inputClassName} inputElementClassName={inputElementClassName} + searchIconName={searchIconName} searchIconClassName={searchIconClassName} search={searchPluginText} onSearchChange={handleSearchPluginTextChange} diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index 1c62e2b8d92..fba271a5061 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -7,6 +7,7 @@ import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from '#i18n' import { useTags } from '@/app/components/plugins/hooks' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' @@ -24,6 +25,17 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte option.label.toLowerCase().includes(searchText.toLowerCase()), ) const selectedTagsLength = tags.length + const handleTagsChange = (nextTags: string[]) => { + const addedTag = nextTags.find((tag) => !tags.includes(tag)) + const removedTag = tags.find((tag) => !nextTags.includes(tag)) + markMarketplaceSiteFilter({ + filter_type: 'category', + selection_mode: 'multi', + filter_value: addedTag ?? removedTag ?? nextTags.at(-1) ?? '', + selected_values: nextTags, + }) + onTagsChange(nextTags) + } return ( <Popover open={open} onOpenChange={setOpen}> @@ -32,7 +44,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte selectedTagsLength={selectedTagsLength} tags={tags} tagsMap={tagsMap} - onTagsChange={onTagsChange} + onTagsChange={handleTagsChange} /> )} {!usedInMarketplace && ( @@ -40,7 +52,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte selectedTagsLength={selectedTagsLength} tags={tags} tagsMap={tagsMap} - onTagsChange={onTagsChange} + onTagsChange={handleTagsChange} /> )} <PopoverContent @@ -74,7 +86,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte <CheckboxGroup aria-label={t(($) => $.allTags, { ns: 'pluginTags' })} value={tags} - onValueChange={(nextTags) => onTagsChange(nextTags)} + onValueChange={handleTagsChange} className="max-h-112 overflow-y-auto p-1" > {filteredOptions.map((option) => ( diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index 9538543ea40..6ddc889c1f5 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -1,16 +1,38 @@ +import type { PluginsSearchParams, PluginsSort } from '@dify/contracts/marketplace' import type { inferParserType } from 'nuqs/server' import type { ActivePluginType } from './constants' import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' -import { PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { getMarketplaceListFilterType } from './utils' export const marketplaceSearchParamsParsers = { category: parseAsStringEnum<ActivePluginType>( Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[], ) .withDefault('all') - .withOptions({ history: 'replace', clearOnDefault: false }), - q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + .withOptions({ history: 'replace', clearOnDefault: false, scroll: false }), + q: parseAsString.withDefault('').withOptions({ history: 'replace', scroll: false }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), + languages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), } export type MarketplaceSearchParams = inferParserType<typeof marketplaceSearchParamsParsers> + +export const shouldSearchMarketplacePlugins = ({ + category, + q, + tags, +}: Pick<MarketplaceSearchParams, 'category' | 'q' | 'tags'>) => + Boolean(q || tags.length > 0 || !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(category)) + +export const getMarketplacePluginsSearchParams = ( + { category, q, tags }: Pick<MarketplaceSearchParams, 'category' | 'q' | 'tags'>, + sort: PluginsSort = DEFAULT_SORT, +): PluginsSearchParams => ({ + query: q, + category: category === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : category, + tags, + sort_by: sort.sortBy, + sort_order: sort.sortOrder, + type: getMarketplaceListFilterType(category), +}) diff --git a/web/app/components/plugins/marketplace/server-budget.ts b/web/app/components/plugins/marketplace/server-budget.ts new file mode 100644 index 00000000000..39249a64441 --- /dev/null +++ b/web/app/components/plugins/marketplace/server-budget.ts @@ -0,0 +1,39 @@ +/** + * How long a server render may wait for Marketplace data before giving up on + * server-side rendering it. + * + * The catalog routes prefetch on the server so results land in the initial HTML + * (crawlers, first paint). Awaiting that prefetch to completion makes the whole + * RSC response hostage to the Marketplace API: with a slow upstream the browser + * sits on the *previous* page with no feedback, which is what "search just spins + * forever" looks like from the outside. Measured against a 3s-delayed API, an + * unbounded await pushed time-to-first-byte to ~7s. + * + * Nothing is lost when the budget expires: the client re-requests whatever is + * missing from the dehydrated state, and TanStack Query is configured to + * dehydrate still-pending queries, so in-flight work streams instead of + * blocking. Server rendering degrades exactly when it is too slow to be worth + * waiting for. + * + * Known limitation: the catalog spends this budget twice in sequence — banners + * in `index.tsx`, then the prefetch in `hydration-server.tsx` — so the worst + * case is 2x. Overlapping them means handing the started prefetch promise down + * instead of letting `HydrateQueryClient` own it, which is a wider change than + * bounding the waits. + */ +const SERVER_PREFETCH_BUDGET_MS = 2_500 + +export async function withinServerBudget(work: Promise<unknown>): Promise<void> { + let cancelBudget = () => {} + try { + await Promise.race([ + work, + new Promise<void>((resolve) => { + const timer = setTimeout(resolve, SERVER_PREFETCH_BUDGET_MS) + cancelBudget = () => clearTimeout(timer) + }), + ]) + } finally { + cancelBudget() + } +} diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index f9c723a6481..a2a826ff7c6 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,4 +1,5 @@ import type { PluginsSearchParams } from '@dify/contracts/marketplace' +import type { ActivePluginType } from './constants' import { useDebounce } from 'ahooks' import { useCallback, useMemo } from 'react' import { @@ -8,52 +9,79 @@ import { useMarketplaceSortValue, useSearchPluginText, } from './atoms' -import { PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' -import { getCollectionsParams, getMarketplaceListFilterType } from './utils' +import { getMarketplacePluginsSearchParams } from './search-params' +import { getCollectionsParams } from './utils' -export function useMarketplaceData() { +export function useMarketplaceData(activePluginTypeOverride?: ActivePluginType) { const [searchPluginTextOriginal] = useSearchPluginText() const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() + const [activePluginTypeFromUrl] = useActivePluginType() + const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl + const isSearchMode = useMarketplaceSearchMode(activePluginType, searchPluginText) const collectionsQuery = useMarketplaceCollectionsAndPlugins( getCollectionsParams(activePluginType), + !isSearchMode, ) const sort = useMarketplaceSortValue() - const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) return undefined - return { - query: searchPluginText, - category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, - tags: filterPluginTags, - sort_by: sort.sortBy, - sort_order: sort.sortOrder, - type: getMarketplaceListFilterType(activePluginType), - } + return getMarketplacePluginsSearchParams( + { + q: searchPluginText, + category: activePluginType, + tags: filterPluginTags, + }, + sort, + ) }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) const pluginsQuery = useMarketplacePlugins(queryParams) const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery const handlePageChange = useCallback(() => { - if (hasNextPage && !isFetching) fetchNextPage() + if (hasNextPage && !isFetching) void fetchNextPage() }, [fetchNextPage, hasNextPage, isFetching]) // Scroll pagination useMarketplaceContainerScroll(handlePageChange) + const pages = pluginsQuery.data?.pages + // Meilisearch resolves ties in `install_count DESC` by internal document + // order, and the sync task rewrites those documents every minute, so + // offset-paginated pages can overlap. Without this, an overlap renders two + // cards with the same React key and remounts the grid. + const plugins = useMemo(() => { + if (!pages) return undefined + const seen = new Set<string>() + return pages.flatMap((page) => + page.plugins.filter((plugin) => { + const key = `${plugin.org}/${plugin.name}` + if (seen.has(key)) return false + seen.add(key) + return true + }), + ) + }, [pages]) + return { marketplaceCollections: collectionsQuery.data?.marketplaceCollections, marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, - plugins: pluginsQuery.data?.pages.flatMap((page) => page.plugins), - pluginsTotal: pluginsQuery.data?.pages[0]?.total, - page: pluginsQuery.data?.pages.length || 1, + plugins, + pluginsTotal: pages?.[0]?.total, + page: pages?.length || 1, isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, + // A superseded query keeps the previous results on screen (placeholderData) + // or has not been issued yet (still debouncing). Both need a quiet pending + // affordance; unmounting the grid instead collapses layout and jumps scroll. + isRefreshing: + pluginsQuery.isPlaceholderData || searchPluginTextOriginal.trim() !== searchPluginText.trim(), + isError: collectionsQuery.isError || pluginsQuery.isError, + refetch: isSearchMode ? pluginsQuery.refetch : collectionsQuery.refetch, isFetchingNextPage, } } diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx new file mode 100644 index 00000000000..e1869ba75f0 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx @@ -0,0 +1,91 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TemplateCard from '../template-card' + +const { mockPush } = vi.hoisted(() => ({ + mockPush: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('../../utils', () => ({ + getTemplateLinkInMarketplace: ( + currentTemplate: MarketplaceTemplate, + params: { language: string; source?: string; theme?: string; view: string }, + ) => + `about:blank?templateId=${currentTemplate.id}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`, +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <div aria-hidden />, +})) + +const template: MarketplaceTemplate = { + id: 'template/one', + template_name: 'Campaign planner', + overview: 'Plan a launch campaign.', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 1200, + categories: ['marketing'], + badges: ['partner'], +} + +describe('TemplateCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens template detail before starting the Dify import flow', async () => { + const user = userEvent.setup() + render( + <ThemeProvider forcedTheme="dark"> + <TemplateCard partnerText="Verified by a Dify partner" template={template} /> + </ThemeProvider>, + ) + + expect(screen.queryByRole('link', { name: 'Campaign planner' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Campaign planner' })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(mockPush).not.toHaveBeenCalled() + + const frame = screen.getByTitle( + 'Campaign planner · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const marketplaceOrigin = new URL(frame.getAttribute('src')!, window.location.href).origin + const installRequest = { + type: 'dify-marketplace:install-template', + templateId: template.id, + } + fireEvent( + window, + new MessageEvent('message', { + data: { ...installRequest, templateId: 'another-template' }, + origin: marketplaceOrigin, + source: frame.contentWindow, + }), + ) + expect(mockPush).not.toHaveBeenCalled() + + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: marketplaceOrigin, + source: frame.contentWindow, + }), + ) + expect(mockPush).toHaveBeenCalledWith('/apps?template-id=template%2Fone') + expect(screen.getByText('dify')).toBeInTheDocument() + expect(screen.getByText('1.2k')).toBeInTheDocument() + expect(screen.getByLabelText('Verified by a Dify partner')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx new file mode 100644 index 00000000000..27de0983450 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx @@ -0,0 +1,137 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import TemplateCollectionList from '../template-collection-list' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useLocale: () => 'en-US', + useTranslation: () => ({ + t: withSelectorKey((key: string) => + key === 'marketplace.carousel.scrollPrevious' ? 'Previous' : key, + ), + }), + } +}) + +vi.mock('../template-card', () => ({ + default: ({ template }: { template: MarketplaceTemplate }) => <div>{template.template_name}</div>, +})) + +const partnerCollection: MarketplaceTemplateCollection = { + name: 'partners', + label: { en_US: 'Partners' }, + description: { en_US: 'Plugins verified by Dify partners.' }, + searchable: false, + search_params: {}, + priority: 0, +} + +const partnerTemplates = Array.from({ length: 9 }, (_, index) => ({ + id: `template-${index}`, + template_name: `Partner template ${index}`, + overview: 'Partner template', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 10, + categories: ['marketing'], +})) as MarketplaceTemplate[] + +const renderPartnerCollection = ({ + templateCount = 9, + standalone = true, + width = 350, +}: { + templateCount?: number + standalone?: boolean + width?: number +} = {}) => + render( + <div + data-testid="collection-shell" + data-marketplace-standalone={standalone || undefined} + style={{ width }} + > + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: partnerTemplates.slice(0, templateCount) }} + viewMoreText="View more" + /> + </div>, + ) + +const getTextRect = (element: Element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect() +} + +describe('Template partner collection header layout', () => { + it('keeps the mobile call to action beside the title and clear of carousel controls', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection() + + const title = screen.getByText('Partners', { exact: true }).element() + const description = screen.getByText('Plugins verified by Dify partners.').element() + const separator = screen.getByText('|').element() + const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element() + const previousButton = screen.getByRole('button', { name: 'Previous' }).element() + + const titleRect = getTextRect(title) + const descriptionRect = description.getBoundingClientRect() + const partnerLinkRect = partnerLink.getBoundingClientRect() + const previousButtonRect = previousButton.getBoundingClientRect() + const titleCenter = titleRect.top + titleRect.height / 2 + const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2 + + expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2) + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8) + expect(descriptionRect.top).toBeGreaterThanOrEqual( + Math.max(titleRect.bottom, partnerLinkRect.bottom), + ) + expect(getComputedStyle(separator).display).toBe('none') + }) + + it('preserves the desktop title and metadata rows', async () => { + await page.viewport(1280, 900) + const screen = await render( + <div className="w-[1200px]" data-marketplace-standalone> + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: partnerTemplates }} + viewMoreText="View more" + /> + </div>, + ) + + const titleRect = screen + .getByText('Partners', { exact: true }) + .element() + .getBoundingClientRect() + const descriptionRect = screen + .getByText('Plugins verified by Dify partners.') + .element() + .getBoundingClientRect() + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom) + expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2) + expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none') + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx new file mode 100644 index 00000000000..4efe7f90d92 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx @@ -0,0 +1,98 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import TemplateCollectionList from '../template-collection-list' + +vi.mock('../template-card', () => ({ + default: ({ template }: { template: MarketplaceTemplate }) => ( + <div data-testid="template-card">{template.template_name}</div> + ), +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + trackMarketplaceSiteEvent: vi.fn(), +})) + +const partnerCollection: MarketplaceTemplateCollection = { + name: 'partners', + label: { en_US: 'Partners' }, + description: { en_US: 'Partner templates' }, + searchable: false, + search_params: {}, + priority: 0, +} + +const featuredCollection: MarketplaceTemplateCollection = { + name: 'featured', + label: { en_US: 'Featured' }, + description: { en_US: 'Featured templates' }, + searchable: false, + search_params: {}, + priority: 1, +} + +const buildTemplates = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => ({ + id: `${prefix}-${index}`, + template_name: `${prefix} ${index}`, + overview: 'Template', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 10, + categories: ['marketing'], + })) as MarketplaceTemplate[] + +describe('TemplateCollectionList carousel', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: 1280, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps carousel navigation for non-partner collections that exceed two rows', () => { + render( + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[featuredCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ featured: buildTemplates('Featured', 9) }} + viewMoreText="View more" + />, + ) + + expect( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Featured' })).toBeInTheDocument() + }) + + it('keeps carousel navigation for partner collections that exceed two rows', () => { + render( + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: buildTemplates('Partner', 9) }} + viewMoreText="View more" + />, + ) + + expect( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Partners' })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts new file mode 100644 index 00000000000..77d55b2e942 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vite-plus/test' +import { + filterTemplatesForLocale, + getTemplateCollectionText, + parseListParam, + resolveTemplateSearchLanguages, +} from '../template-language' + +const template = (id: string, preferredLanguages?: string[]) => ({ + id, + preferred_languages: preferredLanguages, +}) + +const ids = (templates: { id: string }[]) => templates.map(({ id }) => id) + +describe('filterTemplatesForLocale', () => { + it('keeps templates matching the requested language prefix', () => { + const templates = [ + template('en', ['en-US']), + template('zh', ['zh-Hans']), + template('ja', ['ja-JP']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'zh-Hans'))).toEqual(['zh']) + expect(ids(filterTemplatesForLocale(templates, 'en-US'))).toEqual(['en']) + }) + + it('matches unrelated locales instead of collapsing them into "other"', () => { + const templates = [ + template('en', ['en-US']), + template('de', ['de-DE']), + template('fr', ['fr-FR']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['de']) + }) + + it('falls back to English templates when nothing matches the requested language', () => { + const templates = [ + template('en-1', ['en-US']), + template('en-2', ['en-GB']), + template('ja', ['ja-JP']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['en-1', 'en-2']) + }) + + it('falls back to the unfiltered list when neither the locale nor English matches', () => { + const templates = [template('zh', ['zh-Hans']), template('ja', ['ja-JP'])] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['zh', 'ja']) + }) + + it('always keeps language-agnostic templates', () => { + const templates = [ + template('agnostic-none'), + template('agnostic-empty', []), + template('de', ['de-DE']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual([ + 'agnostic-none', + 'agnostic-empty', + 'de', + ]) + }) + + it('normalizes underscore locales', () => { + const templates = [template('zh', ['zh_Hans']), template('en', ['en_US'])] + + expect(ids(filterTemplatesForLocale(templates, 'zh_Hans'))).toEqual(['zh']) + }) +}) + +describe('getTemplateCollectionText', () => { + it('uses the matching collection translation and falls back to English', () => { + const label = { + en_US: 'Featured', + zh_Hans: '精选', + zh_Hant: '精選', + ja_JP: '注目', + } + + expect(getTemplateCollectionText(label, 'zh-Hant')).toBe('精選') + expect(getTemplateCollectionText(label, 'de-DE')).toBe('Featured') + }) + + it('falls back to the first available translation when English is missing', () => { + expect(getTemplateCollectionText({ ja_JP: '注目' }, 'de-DE')).toBe('注目') + expect(getTemplateCollectionText({}, 'de-DE')).toBe('') + }) +}) + +describe('parseListParam', () => { + it('normalizes undefined, comma-separated, and array language values', () => { + expect(parseListParam(undefined)).toEqual([]) + expect(parseListParam('en,zh-Hans')).toEqual(['en', 'zh-Hans']) + expect(parseListParam(['ja', ' other '])).toEqual(['ja', 'other']) + }) +}) + +describe('resolveTemplateSearchLanguages', () => { + it('uses the explicit filter when the visitor picked languages', () => { + expect(resolveTemplateSearchLanguages(['ja'], 'zh-Hans')).toEqual(['ja']) + }) + + it('maps UI locales onto catalog language values when the filter is unset', () => { + expect(resolveTemplateSearchLanguages([], 'en-US')).toEqual(['en']) + expect(resolveTemplateSearchLanguages([], 'zh-Hans')).toEqual(['zh-Hans']) + expect(resolveTemplateSearchLanguages([], 'zh_Hans')).toEqual(['zh-Hans']) + expect(resolveTemplateSearchLanguages([], 'ja-JP')).toEqual(['ja']) + }) + + it('keeps unmatched locale prefixes so pagination is not mixed-language', () => { + expect(resolveTemplateSearchLanguages([], 'de-DE')).toEqual(['de']) + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts new file mode 100644 index 00000000000..34993d58447 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vite-plus/test' +import { buildTemplatesHref } from '../template-links' + +describe('buildTemplatesHref', () => { + it('appends selected languages as a comma-separated query value', () => { + expect(buildTemplatesHref({ category: 'all', languages: ['en', 'ja'] })).toBe( + '/templates?languages=en%2Cja', + ) + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/categories.ts b/web/app/components/plugins/marketplace/templates/categories.ts new file mode 100644 index 00000000000..2d8d02943e9 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/categories.ts @@ -0,0 +1,17 @@ +export const TEMPLATE_CATEGORIES = [ + 'all', + 'marketing', + 'sales', + 'support', + 'operations', + 'it', + 'knowledge', + 'design', + 'others', +] as const + +export type TemplateCategory = (typeof TEMPLATE_CATEGORIES)[number] + +export function isTemplateCategory(value: string | undefined): value is TemplateCategory { + return TEMPLATE_CATEGORIES.includes(value as TemplateCategory) +} diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx new file mode 100644 index 00000000000..4b40c7f32f1 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -0,0 +1,316 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { TemplateCategory } from './categories' +import type { Locale } from '@/i18n-config' +import { cn } from '@langgenius/dify-ui/cn' +import AccountSection from '@/app/components/main-nav/components/account-section' +import { getTranslation } from '@/i18n-config/server' +import { redirect } from '@/next/navigation' +import { + getMarketplaceTemplateCollectionsAndTemplates, + searchMarketplaceTemplates, + TEMPLATE_SEARCH_PAGE_SIZE, +} from '@/service/marketplace-template-discovery' +import { fetchPluginBanners } from '../home/banners' +import CatalogLanguagesFilter from '../home/catalog-languages-filter' +import HomeCatalogNavigation from '../home/home-catalog-navigation' +import HomeCatalogTabs from '../home/home-catalog-tabs' +import HomeHeader from '../home/home-header' +import HomeHero from '../home/home-hero' +import HomeSearch from '../home/home-search' +import { HomeShell } from '../home/home-shell' +import styles from '../home/home-sticky.module.css' +import MarketplaceLiveSearch from '../home/marketplace-live-search' +import { GRID_CLASS } from '../list/collection-constants' +import TemplateCard from './template-card' +import TemplateCategoryNavigation from './template-category-navigation' +import TemplateCollectionList from './template-collection-list' +import { + filterTemplatesForLocale, + parseListParam, + resolveTemplateSearchLanguages, +} from './template-language' +import { buildTemplatesHref, PAGE_LINK_CLASS } from './template-links' +import TemplatePagination from './template-pagination' + +type EmbeddedTemplatesMarketplaceProps = { + category: TemplateCategory + languages?: string | string[] + locale: Locale + page?: number + query: string + sortBy?: string + sortOrder?: string + view?: string +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( + <div className="flex min-h-60 items-center justify-center rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary"> + {children} + </div> + ) +} + +function TemplateGrid({ + partnerText, + templates, +}: { + partnerText: string + templates: MarketplaceTemplate[] +}) { + return ( + <div className={GRID_CLASS}> + {templates.map((template) => ( + <TemplateCard key={template.id} partnerText={partnerText} template={template} /> + ))} + </div> + ) +} + +// The retry link is a plain anchor on purpose: a full navigation re-runs the +// failed (and uncached) server fetch instead of reusing the router cache. +function LoadErrorState({ + message, + retryHref, + retryLabel, +}: { + message: string + retryHref: string + retryLabel: string +}) { + return ( + <div className="flex min-h-60 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary"> + <span>{message}</span> + <a href={retryHref} className={PAGE_LINK_CLASS}> + {retryLabel} + </a> + </div> + ) +} + +export async function EmbeddedTemplatesMarketplace({ + category, + languages, + locale, + page = 1, + query, + sortBy, + sortOrder, + view, +}: EmbeddedTemplatesMarketplaceProps) { + const normalizedQuery = query.trim() + const selectedLanguages = parseListParam(languages) + const searchLanguages = resolveTemplateSearchLanguages(selectedLanguages, locale) + const showCollections = + category === 'all' && !normalizedQuery && view !== 'search' && selectedLanguages.length === 0 + const [ + { t: tPlugin }, + { t: tApp }, + { t: tExplore }, + { t: tPluginTags }, + { t: tCommon }, + collectionsResult, + searchResult, + banners, + ] = await Promise.all([ + getTranslation(locale, 'plugin'), + getTranslation(locale, 'app'), + getTranslation(locale, 'explore'), + getTranslation(locale, 'pluginTags'), + getTranslation(locale, 'common'), + showCollections ? getMarketplaceTemplateCollectionsAndTemplates() : Promise.resolve(null), + showCollections + ? Promise.resolve(null) + : searchMarketplaceTemplates({ + category, + page, + query: normalizedQuery, + sortBy, + sortOrder, + languages: searchLanguages, + }), + fetchPluginBanners(locale, 'templates').catch(() => []), + ]) + const categoryLabels = { + all: tPlugin(($) => $['category.all'], { ns: 'plugin' }), + marketing: tApp(($) => $['marketplace.template.category.marketing'], { ns: 'app' }), + sales: tApp(($) => $['marketplace.template.category.sales'], { ns: 'app' }), + support: tApp(($) => $['marketplace.template.category.support'], { ns: 'app' }), + operations: tApp(($) => $['marketplace.template.category.operations'], { ns: 'app' }), + it: tApp(($) => $['marketplace.template.category.it'], { ns: 'app' }), + knowledge: tApp(($) => $['marketplace.template.category.knowledge'], { ns: 'app' }), + design: tApp(($) => $['marketplace.template.category.design'], { ns: 'app' }), + others: tPluginTags(($) => $['tags.other'], { ns: 'pluginTags' }), + } + const pageCount = Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE) + // An out-of-range ?page= would render a misleading empty state; send the + // visitor to the last page that actually exists instead. + if (searchResult?.ok && searchResult.total > 0 && page > pageCount) { + redirect( + buildTemplatesHref({ + category, + languages: selectedLanguages, + page: pageCount, + query: normalizedQuery, + sortBy, + sortOrder, + view, + }), + ) + } + + const templates = searchResult?.templates ?? [] + // Collection previews have no language query, so they still need a locale + // pass. Search results are already paginated with `searchLanguages`. + const visibleTemplatesByCollection = Object.fromEntries( + (collectionsResult?.collections ?? []).map((collection) => [ + collection.name, + filterTemplatesForLocale( + collectionsResult?.templatesByCollection[collection.name] ?? [], + locale, + ), + ]), + ) + const hasVisibleCollections = (collectionsResult?.collections ?? []).some( + (collection) => (visibleTemplatesByCollection[collection.name]?.length ?? 0) > 0, + ) + const pluginsLabel = tPlugin(($) => $['marketplace.home.plugins'], { ns: 'plugin' }) + const templatesLabel = tPlugin(($) => $['marketplace.home.templates'], { ns: 'plugin' }) + const partnerText = tPlugin(($) => $['marketplace.partnerTip'], { ns: 'plugin' }) + const loadFailed = collectionsResult + ? !collectionsResult.ok + : searchResult + ? !searchResult.ok + : false + const currentHref = buildTemplatesHref({ + category, + languages: selectedLanguages, + page, + query: normalizedQuery, + sortBy, + sortOrder, + view, + }) + const loadErrorState = ( + <LoadErrorState + message={tPlugin(($) => $['marketplace.loadError'], { ns: 'plugin' })} + retryHref={currentHref} + retryLabel={tCommon(($) => $['operation.retry'], { ns: 'common' })} + /> + ) + + return ( + <HomeShell + banners={banners} + isMarketplacePlatform={false} + page="templates" + header={ + <HomeHeader + activeTab="templates" + actions={ + <div className="p-0.5"> + <AccountSection compact /> + </div> + } + catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }} + isMarketplacePlatform={false} + /> + } + hero={ + <HomeHero + isMarketplacePlatform={false} + title={templatesLabel} + subtitle={tExplore(($) => $['apps.description'], { ns: 'explore' })} + /> + } + search={ + <HomeSearch enableSearchShortcut={false}> + <MarketplaceLiveSearch + action={category === 'all' ? '/templates' : `/templates/${category}`} + className="w-full" + placeholder={tApp(($) => $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' })} + preserveParams={selectedLanguages.length ? { languages: selectedLanguages } : undefined} + query={query} + /> + </HomeSearch> + } + navigation={ + <HomeCatalogNavigation + isMarketplacePlatform={false} + catalogTabs={ + <HomeCatalogTabs + activeTab="templates" + isMarketplacePlatform={false} + labels={{ plugins: pluginsLabel, templates: templatesLabel }} + /> + } + catalogCategories={ + <TemplateCategoryNavigation + activeCategory={category} + ariaLabel={tPlugin(($) => $.allCategories, { ns: 'plugin' })} + labels={categoryLabels} + languages={selectedLanguages} + query={query} + /> + } + catalogTrailing={<CatalogLanguagesFilter />} + /> + } + > + {/* The app shell already renders the main landmark; use a plain div + to avoid nested main elements. */} + <div + className={cn( + 'relative flex grow flex-col bg-background-default px-8 py-2', + styles.catalogContent, + )} + > + {loadFailed ? ( + loadErrorState + ) : collectionsResult ? ( + hasVisibleCollections ? ( + <TemplateCollectionList + becomePartnerText={tPlugin(($) => $['marketplace.becomePartner'], { + ns: 'plugin', + })} + collections={collectionsResult.collections} + locale={locale} + partnerText={partnerText} + templatesByCollection={visibleTemplatesByCollection} + viewMoreText={tPlugin(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + /> + ) : ( + <EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState> + ) + ) : ( + <> + {/* The locale filter runs after pagination, so the API total + does not describe what is on screen; show the number of + templates actually rendered on this page instead. */} + <div className="mb-5 text-right text-sm text-text-tertiary"> + {tExplore(($) => $['apps.resultNum'], { ns: 'explore', num: templates.length })} + </div> + {templates.length > 0 ? ( + <TemplateGrid partnerText={partnerText} templates={templates} /> + ) : ( + <EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState> + )} + <TemplatePagination + category={category} + languages={selectedLanguages} + navigationLabel={tCommon(($) => $['pagination.pageNumber'], { ns: 'common' })} + nextLabel={tCommon(($) => $['pagination.next'], { ns: 'common' })} + page={page} + pageCount={pageCount} + previousLabel={tCommon(($) => $['pagination.previous'], { ns: 'common' })} + query={normalizedQuery} + sortBy={sortBy} + sortOrder={sortOrder} + view={view} + /> + </> + )} + </div> + </HomeShell> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-card.tsx b/web/app/components/plugins/marketplace/templates/template-card.tsx new file mode 100644 index 00000000000..58ee195db6f --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-card.tsx @@ -0,0 +1,109 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import { cn } from '@langgenius/dify-ui/cn' +import { useBoolean } from 'ahooks' +import { useCallback } from 'react' +import AppIcon from '@/app/components/base/app-icon' +import Partner from '@/app/components/plugins/base/badges/partner' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { useRouter } from '@/next/navigation' +import { formatNumberAbbreviated } from '@/utils/format' +import { getIconFromMarketPlace } from '@/utils/get-icon' +import TemplateDetailDialog from './template-detail-dialog' + +type TemplateCardProps = { + template: MarketplaceTemplate + className?: string + partnerText: string +} + +const MAX_VISIBLE_PLUGIN_DEPENDENCIES = 7 + +export default function TemplateCard({ template, className, partnerText }: TemplateCardProps) { + const router = useRouter() + const [isDetailOpen, { setTrue: showDetail, setFalse: hideDetail }] = useBoolean(false) + const publisher = + template.publisher_handle || template.publisher_unique_handle || template.creator_email || '' + const visiblePlugins = template.deps_plugins?.slice(0, MAX_VISIBLE_PLUGIN_DEPENDENCIES) ?? [] + const remainingPluginCount = Math.max( + 0, + (template.deps_plugins?.length ?? 0) - MAX_VISIBLE_PLUGIN_DEPENDENCIES, + ) + const imageUrl = template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` + : undefined + const handleOpenChange = (open: boolean) => { + if (open) showDetail() + else hideDetail() + } + const handleInstall = useCallback(() => { + hideDetail() + router.push(`/apps?template-id=${encodeURIComponent(template.id)}`) + }, [hideDetail, router, template.id]) + + return ( + <> + <article + className={cn( + 'relative flex h-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', + className, + )} + > + <div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2"> + <AppIcon + size="large" + iconType={imageUrl ? 'image' : 'emoji'} + icon={imageUrl ? undefined : template.icon || '📄'} + imageUrl={imageUrl} + background={template.icon_background} + /> + <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5"> + <div className="flex items-center"> + <button + type="button" + onClick={showDetail} + className="truncate text-left system-md-medium text-text-primary outline-hidden after:absolute after:inset-0 focus-visible:after:ring-2 focus-visible:after:ring-state-accent-solid focus-visible:after:ring-inset" + > + {template.template_name} + </button> + {template.badges?.includes('partner') && ( + <Partner className="relative z-[1] ml-0.5 size-4 shrink-0" text={partnerText} /> + )} + </div> + <div className="flex items-center gap-2 system-xs-regular text-text-tertiary"> + {publisher && <span className="truncate">{publisher}</span>} + {publisher && <span>·</span>} + <span>{formatNumberAbbreviated(template.usage_count)}</span> + </div> + </div> + </div> + <div className="min-h-8 px-4 pt-1 pb-2 system-xs-regular text-text-secondary"> + <p className="line-clamp-2" title={template.overview}> + {template.overview} + </p> + </div> + <div className="mt-auto flex min-h-7 items-center gap-1 px-4 py-1"> + {visiblePlugins.map((pluginId) => ( + <img + key={pluginId} + className="size-6 rounded-md border-[0.5px] border-effects-icon-border object-cover" + src={getIconFromMarketPlace(pluginId)} + alt="" + title={pluginId} + /> + ))} + {remainingPluginCount > 0 && ( + <span className="system-xs-regular text-text-tertiary">+{remainingPluginCount}</span> + )} + </div> + </article> + <TemplateDetailDialog + open={isDetailOpen} + template={template} + onInstall={handleInstall} + onOpenChange={handleOpenChange} + /> + </> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx b/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx new file mode 100644 index 00000000000..dfd809ef5fb --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx @@ -0,0 +1,56 @@ +import type { TemplateCategory } from './categories' +import { cn } from '@langgenius/dify-ui/cn' +import MarketplaceFilterTrackLink from '../filter-track-link' +import pluginTypeStyles from '../plugin-type-switch.module.css' +import { TEMPLATE_CATEGORIES } from './categories' + +export type TemplateCategoryLabels = Record<TemplateCategory, string> + +export default function TemplateCategoryNavigation({ + activeCategory, + ariaLabel, + labels, + languages, + query, +}: { + activeCategory: TemplateCategory + ariaLabel: string + labels: TemplateCategoryLabels + languages: string[] + query: string +}) { + return ( + <nav + aria-label={ariaLabel} + className="flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto" + > + {TEMPLATE_CATEGORIES.map((category) => { + const searchParams = new URLSearchParams() + if (query) searchParams.set('q', query) + if (languages.length) searchParams.set('languages', languages.join(',')) + const queryString = searchParams.toString() + const href = `/templates/${category}${queryString ? `?${queryString}` : ''}` + + return ( + <MarketplaceFilterTrackLink + key={category} + href={href} + scroll={false} + aria-current={category === activeCategory ? 'page' : undefined} + filterType="category" + filterValue={category} + selectedValues={[category]} + trackFilter={category !== activeCategory} + className={cn( + 'flex h-8 min-w-12 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + pluginTypeStyles.homeItem, + category === activeCategory && pluginTypeStyles.homeItemActive, + )} + > + {labels[category]} + </MarketplaceFilterTrackLink> + ) + })} + </nav> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-collection-list.tsx b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx new file mode 100644 index 00000000000..735db4f6b64 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx @@ -0,0 +1,172 @@ +'use client' + +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { cn } from '@langgenius/dify-ui/cn' +import Link from '@/next/link' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import Carousel from '../list/carousel' +import { + BECOME_PARTNER_URL, + GRID_CLASS, + PARTNER_COLLECTION_NAMES, +} from '../list/collection-constants' +import styles from '../list/partner-header.module.css' +import { useCarouselItemsPerPage } from '../list/use-carousel-items-per-page' +import TemplateCard from './template-card' +import { getTemplateCollectionText } from './template-language' + +type TemplateCollectionListProps = { + becomePartnerText: string + collections: MarketplaceTemplateCollection[] + locale: string + partnerText: string + /** + * Templates per collection, already filtered for the request locale by the + * caller; this component only renders what it receives. + */ + templatesByCollection: Record<string, MarketplaceTemplate[]> + viewMoreText: string +} + +function getViewMoreHref(collection: MarketplaceTemplateCollection) { + const searchParams = new URLSearchParams({ view: 'search' }) + const collectionSearch = collection.search_params + + if (collectionSearch?.query) searchParams.set('q', collectionSearch.query) + if (collectionSearch?.sort_by) searchParams.set('sort_by', collectionSearch.sort_by) + if (collectionSearch?.sort_order) searchParams.set('sort_order', collectionSearch.sort_order) + + return `/templates/all?${searchParams.toString()}` +} + +export default function TemplateCollectionList({ + becomePartnerText, + collections, + locale, + partnerText, + templatesByCollection, + viewMoreText, +}: TemplateCollectionListProps) { + const itemsPerPage = useCarouselItemsPerPage() + + return collections.map((collection) => { + const templates = templatesByCollection[collection.name] ?? [] + + if (!templates.length) return null + + const isPartnerCollection = PARTNER_COLLECTION_NAMES.has(collection.name) + const hasMultiplePages = !collection.searchable && templates.length > itemsPerPage + + return ( + <section key={collection.name} className="py-3"> + <div className="mb-2 flex items-end justify-between gap-4"> + <div + className={cn( + 'min-w-0', + isPartnerCollection && styles.partnerHeader, + isPartnerCollection && hasMultiplePages && styles.partnerHeaderWithNavigation, + )} + > + <h2 + className={cn( + 'title-xl-semi-bold text-text-primary', + isPartnerCollection && styles.partnerTitle, + )} + > + {getTemplateCollectionText(collection.label, locale)} + </h2> + <div + className={cn( + 'flex flex-wrap items-center gap-x-2 system-xs-regular text-text-tertiary', + isPartnerCollection && styles.partnerMetadata, + )} + > + {isPartnerCollection ? ( + <span className={styles.partnerDescription}> + {getTemplateCollectionText(collection.description, locale)} + </span> + ) : ( + getTemplateCollectionText(collection.description, locale) + )} + {isPartnerCollection && ( + <> + <span className={cn(styles.partnerSeparator, 'text-divider-regular')}>|</span> + <a + href={BECOME_PARTNER_URL} + target="_blank" + rel="noopener noreferrer" + className={cn( + styles.partnerAction, + 'flex items-center gap-x-0.5 text-text-accent hover:underline', + )} + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'Become a Partner', + }) + }} + > + <span className={styles.partnerActionLabel}>{becomePartnerText}</span> + <span + aria-hidden + className={cn(styles.partnerActionIcon, 'i-ri-external-link-line size-3')} + /> + </a> + </> + )} + </div> + </div> + {collection.searchable && ( + <Link + href={getViewMoreHref(collection)} + className="flex shrink-0 items-center system-xs-medium text-text-accent hover:underline" + > + {viewMoreText} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </Link> + )} + </div> + {collection.searchable ? ( + <div className={GRID_CLASS}> + {templates.slice(0, 4).map((template) => ( + <TemplateCard key={template.id} partnerText={partnerText} template={template} /> + ))} + </div> + ) : ( + <Carousel + pages={Array.from( + { length: Math.ceil(templates.length / itemsPerPage) }, + (_, pageIndex) => { + const pageTemplates = templates.slice( + pageIndex * itemsPerPage, + (pageIndex + 1) * itemsPerPage, + ) + + return { + id: `${collection.name}-${itemsPerPage}-${pageIndex}`, + content: ( + <div className={cn(GRID_CLASS)}> + {pageTemplates.map((template) => ( + <div key={template.id} className="min-w-0 *:w-full"> + <TemplateCard partnerText={partnerText} template={template} /> + </div> + ))} + </div> + ), + } + }, + )} + ariaLabel={getTemplateCollectionText(collection.label, locale)} + showNavigation + showPagination + autoPlay={isPartnerCollection} + autoPlayInterval={5000} + pauseWhenOffscreen + /> + )} + </section> + ) + }) +} diff --git a/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx b/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx new file mode 100644 index 00000000000..1e230705e9e --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx @@ -0,0 +1,63 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import { useTheme } from 'next-themes' +import { useCallback } from 'react' +import { useLocale, useTranslation } from '#i18n' +import MarketplaceDetailDialogFrame from '../detail-dialog/frame' +import { getTemplateLinkInMarketplace } from '../utils' + +const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-template' + +type TemplateDetailDialogProps = { + open: boolean + template: MarketplaceTemplate + onInstall: () => void + onOpenChange: (open: boolean) => void +} + +export default function TemplateDetailDialog({ + open, + template, + onInstall, + onOpenChange, +}: TemplateDetailDialogProps) { + const { t } = useTranslation() + const locale = useLocale() + // resolvedTheme maps the "system" preference to the concrete light/dark + // value the marketplace page expects. + const { resolvedTheme } = useTheme() + const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' }) + const detailURL = getTemplateLinkInMarketplace(template, { + language: locale, + source: globalThis.location?.origin, + theme: resolvedTheme, + view: 'modal', + }) + const handleMessage = useCallback( + (data: unknown) => { + if ( + typeof data !== 'object' || + data === null || + !('type' in data) || + !('templateId' in data) || + data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE || + data.templateId !== template.id + ) + return + + onInstall() + }, + [onInstall, template.id], + ) + + return ( + <MarketplaceDetailDialogFrame + open={open} + src={detailURL} + title={`${template.template_name} · ${detailLabel}`} + onMessage={handleMessage} + onOpenChange={onOpenChange} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-language.ts b/web/app/components/plugins/marketplace/templates/template-language.ts new file mode 100644 index 00000000000..c2d10aa2e0e --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-language.ts @@ -0,0 +1,67 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' + +export const LANGUAGE_OPTIONS = [ + { value: 'en', label: 'English', nativeLabel: 'English' }, + { value: 'zh-Hans', label: 'Simplified Chinese', nativeLabel: '中文' }, + { value: 'ja', label: 'Japanese', nativeLabel: '日本語' }, + { value: 'other', label: 'Other', nativeLabel: 'Other' }, +] as const + +export function parseListParam(value?: string | string[]) { + if (!value) return [] + const parts = Array.isArray(value) ? value : value.split(',') + return parts.map((part) => part.trim()).filter(Boolean) +} + +const getLanguagePrefix = (locale: string) => locale.toLowerCase().split(/[-_]/)[0] ?? '' + +function getSearchLanguagesForLocale(locale: string) { + const requestedLanguage = getLanguagePrefix(locale) + if (!requestedLanguage) return ['en'] + if (requestedLanguage === 'zh') return ['zh-Hans'] + + const knownOption = LANGUAGE_OPTIONS.find( + (option) => option.value !== 'other' && getLanguagePrefix(option.value) === requestedLanguage, + ) + if (knownOption) return [knownOption.value] + + return [requestedLanguage] +} + +export function resolveTemplateSearchLanguages(selectedLanguages: string[], locale: string) { + return selectedLanguages.length > 0 ? selectedLanguages : getSearchLanguagesForLocale(locale) +} + +/** + * Keeps the templates matching the requested locale's language. Templates + * without language metadata are treated as language-agnostic and always kept. + * When no template matches the requested language, the list explicitly falls + * back to English templates (and finally to the unfiltered list) so locales + * such as German render real content instead of an empty state. + */ +export function filterTemplatesForLocale< + T extends Pick<MarketplaceTemplate, 'preferred_languages'>, +>(templates: T[], locale: string) { + const requestedLanguage = getLanguagePrefix(locale) + + const filterByLanguage = (languagePrefix: string) => + templates.filter((template) => { + const preferredLanguages = template.preferred_languages ?? [] + if (preferredLanguages.length === 0) return true + return preferredLanguages.some((language) => getLanguagePrefix(language) === languagePrefix) + }) + + const requestedMatches = filterByLanguage(requestedLanguage) + if (requestedMatches.length > 0) return requestedMatches + + const englishMatches = requestedLanguage === 'en' ? [] : filterByLanguage('en') + if (englishMatches.length > 0) return englishMatches + + return templates +} + +export function getTemplateCollectionText(value: Record<string, string>, locale: string) { + const localeKey = locale.replace('-', '_') + + return value[localeKey] || value.en_US || Object.values(value)[0] || '' +} diff --git a/web/app/components/plugins/marketplace/templates/template-links.ts b/web/app/components/plugins/marketplace/templates/template-links.ts new file mode 100644 index 00000000000..fc0ae9b9425 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-links.ts @@ -0,0 +1,37 @@ +import type { TemplateCategory } from './categories' + +export const PAGE_LINK_CLASS = + 'flex h-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +export const PAGE_LINK_DISABLED_CLASS = + 'flex h-8 cursor-not-allowed items-center justify-center rounded-lg border-[0.5px] border-divider-subtle px-3 system-sm-medium text-text-quaternary' + +export type TemplatesHrefOptions = { + category: TemplateCategory + languages?: string[] + page?: number + query?: string + sortBy?: string + sortOrder?: string + view?: string +} + +export function buildTemplatesHref({ + category, + languages, + page = 1, + query, + sortBy, + sortOrder, + view, +}: TemplatesHrefOptions) { + const searchParams = new URLSearchParams() + if (query) searchParams.set('q', query) + if (sortBy) searchParams.set('sort_by', sortBy) + if (sortOrder) searchParams.set('sort_order', sortOrder) + if (view) searchParams.set('view', view) + if (languages?.length) searchParams.set('languages', languages.join(',')) + if (page > 1) searchParams.set('page', String(page)) + const queryString = searchParams.toString() + const basePath = category === 'all' ? '/templates' : `/templates/${category}` + return queryString ? `${basePath}?${queryString}` : basePath +} diff --git a/web/app/components/plugins/marketplace/templates/template-pagination.tsx b/web/app/components/plugins/marketplace/templates/template-pagination.tsx new file mode 100644 index 00000000000..9cac5515cb1 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-pagination.tsx @@ -0,0 +1,70 @@ +import type { TemplateCategory } from './categories' +import Link from '@/next/link' +import { buildTemplatesHref, PAGE_LINK_CLASS, PAGE_LINK_DISABLED_CLASS } from './template-links' + +// Server-rendered pagination: plain links keep the search results reachable +// beyond the first page without any client-side state. +export default function TemplatePagination({ + category, + languages, + navigationLabel, + nextLabel, + page, + pageCount, + previousLabel, + query, + sortBy, + sortOrder, + view, +}: { + category: TemplateCategory + languages?: string[] + navigationLabel: string + nextLabel: string + page: number + pageCount: number + previousLabel: string + query: string + sortBy?: string + sortOrder?: string + view?: string +}) { + if (pageCount <= 1) return null + + const buildHref = (targetPage: number) => + buildTemplatesHref({ + category, + languages, + page: targetPage, + query, + sortBy, + sortOrder, + view, + }) + + return ( + <nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4"> + {page > 1 ? ( + <Link href={buildHref(page - 1)} className={PAGE_LINK_CLASS}> + {previousLabel} + </Link> + ) : ( + <span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}> + {previousLabel} + </span> + )} + <span aria-current="page" className="system-sm-regular text-text-tertiary"> + {page} / {pageCount} + </span> + {page < pageCount ? ( + <Link href={buildHref(page + 1)} className={PAGE_LINK_CLASS}> + {nextLabel} + </Link> + ) : ( + <span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}> + {nextLabel} + </span> + )} + </nav> + ) +} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index acc77a84ebf..7f51d4368ef 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,7 +1,7 @@ import type { CollectionsAndPluginsSearchParams, - MarketplaceCollection, MarketplacePlugin, + MarketplaceTemplate, PluginsSearchParams, } from '@dify/contracts/marketplace' import type { ActivePluginType } from './constants' @@ -70,72 +70,97 @@ export const getPluginDetailLinkInMarketplace = ( return `/plugin/${org}/${name}` } +export const getTemplateLinkInMarketplace = ( + template: Pick< + MarketplaceTemplate, + 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name' + >, + params?: Record<string, string | undefined>, +) => { + const publisher = template.publisher_handle || template.publisher_unique_handle || 'template' + const path = `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}` + + return getMarketplaceUrl(path, { + ...params, + templateId: template.id, + }) +} + export const getMarketplaceCategoryUrl = ( category?: string, params?: Record<string, string | undefined>, ) => { return getMarketplaceUrl(category ? `/plugins/${category}` : '/plugins', params) } +// One collections response lists every catalog carousel and each needs its own +// plugins request. Firing them all at once head-of-line blocks on the browser's +// per-origin connection cap, so the whole catalog waits on the slowest tail +// request — and every one of those is a request the next search has to abort. +const COLLECTION_PLUGINS_CONCURRENCY = 4 + export const getMarketplacePluginsByCollectionId = async ( collectionId: string, query?: CollectionsAndPluginsSearchParams, options?: MarketplaceFetchOptions, ) => { - let plugins: Plugin[] = [] - - try { - const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins( - { - params: { - collectionId, - }, - body: query ?? {}, + const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins( + { + params: { + collectionId, }, - { - signal: options?.signal, - }, - ) - plugins = (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) => - getFormattedPlugin(plugin), - ) - } catch { - plugins = [] - } + body: query ?? {}, + }, + { + signal: options?.signal, + }, + ) - return plugins + return (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) => + getFormattedPlugin(plugin), + ) } export const getMarketplaceCollectionsAndPlugins = async ( query?: CollectionsAndPluginsSearchParams, options?: MarketplaceFetchOptions, ) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} - try { - const marketplaceCollectionsDataJson = await marketplaceClient.collections( - { - query: { - ...query, - page: 1, - page_size: 100, - }, + // Deliberately not wrapped in a catch: a swallowed failure resolves as an + // empty catalog, which react-query caches as a success for the whole + // staleTime and renders as "nothing here" with no retry and no error signal. + const marketplaceCollectionsDataJson = await marketplaceClient.collections( + { + query: { + ...query, + page: 1, + page_size: 100, }, - { - signal: options?.signal, - }, - ) - marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] - await Promise.all( - marketplaceCollections.map(async (collection: MarketplaceCollection) => { - const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) + }, + { + signal: options?.signal, + }, + ) + const marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] + const marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} - marketplaceCollectionPluginsMap[collection.name] = plugins - }), - ) - } catch { - marketplaceCollections = [] - marketplaceCollectionPluginsMap = {} + const pending = [...marketplaceCollections] + const fetchCollectionPlugins = async () => { + for (let collection = pending.shift(); collection; collection = pending.shift()) { + try { + marketplaceCollectionPluginsMap[collection.name] = + await getMarketplacePluginsByCollectionId(collection.name, query, options) + } catch { + // One empty carousel beats a blank catalog: the collection list itself + // loaded, so render what did arrive. + marketplaceCollectionPluginsMap[collection.name] = [] + } + } } + await Promise.all( + Array.from( + { length: Math.min(COLLECTION_PLUGINS_CONCURRENCY, pending.length) }, + fetchCollectionPlugins, + ), + ) return { marketplaceCollections, @@ -159,39 +184,35 @@ export const getMarketplacePlugins = async ( const { query, sort_by, sort_order, category, tags, type, page_size = 40 } = queryParams - try { - const res = await marketplaceClient.searchAdvanced( - { - params: { - kind: type === 'bundle' ? 'bundles' : 'plugins', - }, - body: { - page: pageParam, - page_size, - query, - sort_by, - sort_order, - category: category !== 'all' ? category : '', - tags, - }, + // Errors propagate on purpose. Returning a synthesized empty page here made + // every backend failure — and every aborted keystroke — look like a + // successful zero-result search: react-query never saw isError, never + // retried, cached the emptiness, reported total 0 to the analytics flush, and + // permanently killed getNextPageParam for that key. + const res = await marketplaceClient.searchAdvanced( + { + params: { + kind: type === 'bundle' ? 'bundles' : 'plugins', }, - { signal }, - ) - const resPlugins = res.data.bundles || res.data.plugins || [] + body: { + page: pageParam, + page_size, + query, + sort_by, + sort_order, + category: category !== 'all' ? category : '', + tags, + }, + }, + { signal }, + ) + const resPlugins = res.data.bundles || res.data.plugins || [] - return { - plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)), - total: res.data.total, - page: pageParam, - page_size, - } - } catch { - return { - plugins: [], - total: 0, - page: pageParam, - page_size, - } + return { + plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + page_size, } } diff --git a/web/app/components/plugins/marketplace/view.tsx b/web/app/components/plugins/marketplace/view.tsx new file mode 100644 index 00000000000..4737daf3801 --- /dev/null +++ b/web/app/components/plugins/marketplace/view.tsx @@ -0,0 +1,75 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ActivePluginType } from './constants' +import type { HomeCatalogTabLabels } from './home/home-catalog-tabs' +import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' +import Description from './description' +import MarketplaceHome from './home' +import ListWrapper from './list/list-wrapper' +import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' + +type MarketplaceVariant = 'default' | 'home' + +export type MarketplaceViewProps = { + banners: PluginBanner[] + showInstallButton?: boolean + linkToMarketplaceDetail?: boolean + pluginTypeSwitchClassName?: string + isMarketplacePlatform?: boolean + marketplaceNav?: React.ReactNode + variant?: MarketplaceVariant + homeHeaderActions?: React.ReactNode + homeCatalogLabels?: HomeCatalogTabLabels + homeCatalogCategories?: React.ReactNode + homeActivePluginType?: ActivePluginType + homeSearch?: React.ReactNode + language?: string +} + +export function MarketplaceView({ + banners, + showInstallButton = false, + linkToMarketplaceDetail = false, + pluginTypeSwitchClassName, + isMarketplacePlatform = false, + marketplaceNav, + variant = 'default', + homeHeaderActions, + homeCatalogLabels, + homeCatalogCategories, + homeActivePluginType, + homeSearch, + language, +}: MarketplaceViewProps) { + return ( + <PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}> + {variant === 'home' ? ( + <MarketplaceHome + actions={homeHeaderActions} + activePluginType={homeActivePluginType} + banners={banners} + catalogCategories={homeCatalogCategories} + catalogLabels={homeCatalogLabels} + search={homeSearch} + isMarketplacePlatform={isMarketplacePlatform} + language={language} + linkToMarketplaceDetail={linkToMarketplaceDetail} + showInstallButton={showInstallButton} + /> + ) : ( + <> + <Description + isMarketplacePlatform={isMarketplacePlatform} + marketplaceNav={marketplaceNav} + /> + {!isMarketplacePlatform && ( + <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> + )} + <ListWrapper + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + /> + </> + )} + </PluginInstallPermissionProviderGuard> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index 22f5a212cab..094898ea58e 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -4,7 +4,6 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { Drawer, - DrawerBackdrop, DrawerContent, DrawerPopup, DrawerPortal, @@ -68,18 +67,18 @@ const PluginDetailPanel: FC<Props> = ({ return ( <Drawer open={!!detail} - modal + modal={false} + disablePointerDismissal swipeDirection="right" onOpenChange={(open) => { if (!open) onHide() }} > <DrawerPortal> - <DrawerBackdrop className="bg-transparent" /> - <DrawerViewport> + <DrawerViewport className="pointer-events-none"> <DrawerPopup className={cn( - 'justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-100 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', + 'pointer-events-auto touch-auto justify-start bg-components-panel-bg! p-0! shadow-xl data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-100 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-2xl data-[swipe-direction=right]:border-[0.5px] data-[swipe-direction=right]:border-components-panel-border', )} > <DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0"> diff --git a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx index 95215e021de..b988542d401 100644 --- a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx @@ -32,13 +32,13 @@ vi.mock('../../hooks', () => ({ }), })) -const mockCurrentPluginID = vi.fn((): string | undefined => undefined) -const mockSetCurrentPluginID = vi.fn() +const mockSelectedItem = vi.fn((): { type: 'plugin'; id: string } | undefined => undefined) +const mockSetSelectedItem = vi.fn() vi.mock('../../plugin-page/context', () => ({ usePluginPageContext: (selector: (v: Record<string, unknown>) => unknown) => { const context = { - currentPluginID: mockCurrentPluginID(), - setCurrentPluginID: mockSetCurrentPluginID, + selectedItem: mockSelectedItem(), + setSelectedItem: mockSetSelectedItem, } return selector(context) }, @@ -174,7 +174,7 @@ describe('PluginItem', () => { beforeEach(() => { vi.clearAllMocks() mockTheme.mockReturnValue('light') - mockCurrentPluginID.mockReturnValue(undefined) + mockSelectedItem.mockReturnValue(undefined) mockEnableMarketplace.mockReturnValue(true) mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('1.0.0')) mockGetValueFromI18nObject.mockImplementation((obj: Record<string, string>) => obj?.en_US || '') @@ -588,7 +588,7 @@ describe('PluginItem', () => { // ==================== User Interactions Tests ==================== describe('User Interactions', () => { - it('should call setCurrentPluginID when plugin is clicked', () => { + it('should select the plugin when its card is clicked', () => { // Arrange const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) @@ -598,12 +598,15 @@ describe('PluginItem', () => { fireEvent.click(pluginContainer) // Assert - expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id') + expect(mockSetSelectedItem).toHaveBeenCalledWith({ + type: 'plugin', + id: 'test-plugin-id', + }) }) it('should highlight selected plugin', () => { // Arrange - mockCurrentPluginID.mockReturnValue('test-plugin-id') + mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'test-plugin-id' }) const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) // Act @@ -611,12 +614,14 @@ describe('PluginItem', () => { // Assert const pluginContainer = container.firstChild as HTMLElement - expect(pluginContainer).toHaveClass('border-components-option-card-option-selected-border') + expect(pluginContainer).toHaveClass( + 'after:inset-ring-components-option-card-option-selected-border', + ) }) it('should not highlight unselected plugin', () => { // Arrange - mockCurrentPluginID.mockReturnValue('other-plugin-id') + mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'other-plugin-id' }) const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) // Act @@ -625,7 +630,7 @@ describe('PluginItem', () => { // Assert const pluginContainer = container.firstChild as HTMLElement expect(pluginContainer).not.toHaveClass( - 'border-components-option-card-option-selected-border', + 'after:inset-ring-components-option-card-option-selected-border', ) }) @@ -638,8 +643,8 @@ describe('PluginItem', () => { const actionArea = screen.getByTestId('plugin-action').parentElement fireEvent.click(actionArea!) - // Assert - setCurrentPluginID should not be called - expect(mockSetCurrentPluginID).not.toHaveBeenCalled() + // Assert - selecting the plugin should not be triggered + expect(mockSetSelectedItem).not.toHaveBeenCalled() }) it('should only reveal actions on card hover or focus', () => { @@ -651,9 +656,18 @@ describe('PluginItem', () => { // Assert expect(screen.getByTestId('plugin-action').parentElement).toHaveClass( + 'absolute', + 'top-1/2', + 'right-0', + '-translate-y-1/2', + 'pointer-events-none', 'opacity-0', + 'group-hover/plugin-item:pointer-events-auto', 'group-hover/plugin-item:opacity-100', - 'focus-within:opacity-100', + 'group-focus-within/plugin-item:pointer-events-auto', + 'group-focus-within/plugin-item:opacity-100', + '[@media(hover:none)]:pointer-events-auto', + '[@media(hover:none)]:opacity-100', ) }) }) diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 05d0ed90da5..db723f6cfed 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -47,8 +47,10 @@ const PluginItem: FC<Props> = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const currentPluginID = usePluginPageContext((v) => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) + const selectedPluginID = usePluginPageContext((v) => + v.selectedItem?.type === 'plugin' ? v.selectedItem.id : undefined, + ) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const { refreshPluginList } = useRefreshPluginList() const { @@ -118,19 +120,20 @@ const PluginItem: FC<Props> = ({ return ( <div className={cn( - 'group/plugin-item relative overflow-hidden rounded-xl border-[1.5px] border-background-section-burn p-1', - currentPluginID === plugin_id && 'border-components-option-card-option-selected-border', + 'group/plugin-item relative flex min-w-[min(100%,496px)] flex-1 cursor-pointer flex-col overflow-hidden rounded-xl p-0.75', + selectedPluginID === plugin_id && + "after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:inset-ring-[1.5px] after:inset-ring-components-option-card-option-selected-border after:content-['']", source === PluginSource.debugging ? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]' : 'bg-background-section-burn', )} onClick={() => { - setCurrentPluginID(plugin.plugin_id) + setSelectedItem({ type: 'plugin', id: plugin.plugin_id }) }} > <div className={cn( - 'relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', + 'relative rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3', className, )} > @@ -186,10 +189,14 @@ const PluginItem: FC<Props> = ({ } /> </div> - <div className="flex items-center justify-between"> - <Description text={descriptionText} descriptionLineRows={1}></Description> + <div className="relative flex h-4 min-w-0 items-center"> + <Description + className="min-w-0 flex-1 group-focus-within/plugin-item:pr-20 group-hover/plugin-item:pr-20 [@media(hover:none)]:pr-20" + text={descriptionText} + descriptionLineRows={1} + /> <div - className="opacity-0 transition-opacity group-hover/plugin-item:opacity-100 focus-within:opacity-100" + className="pointer-events-none absolute top-1/2 right-0 -translate-y-1/2 opacity-0 transition-opacity group-focus-within/plugin-item:pointer-events-auto group-focus-within/plugin-item:opacity-100 group-hover/plugin-item:pointer-events-auto group-hover/plugin-item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100" onClick={(e) => e.stopPropagation()} > <Action @@ -210,7 +217,7 @@ const PluginItem: FC<Props> = ({ </div> </div> </div> - <div className="mt-1.5 mb-1 flex h-4 items-center gap-x-2 px-4"> + <div className="flex h-6.5 items-center gap-x-2 px-3 pt-1.5 pb-1"> {/* Organization & Name */} <div className="flex grow items-center overflow-hidden"> <OrgInfo diff --git a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx index 8a42c0773b8..49373705f9c 100644 --- a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx @@ -1,5 +1,6 @@ import type { ReactElement, ReactNode } from 'react' -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createConsoleQueryWrapper } from '@/test/console/query-data' @@ -33,15 +34,22 @@ const renderWithProviders = ( } const Consumer = () => { - const currentPluginID = usePluginPageContext((v) => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) + const selectedItem = usePluginPageContext((v) => v.selectedItem) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const options = usePluginPageContext((v) => v.options) return ( <div> - <output aria-label="Current plugin">{currentPluginID ?? 'none'}</output> + <output aria-label="Selected item"> + {selectedItem ? `${selectedItem.type}:${selectedItem.id}` : 'none'} + </output> <output aria-label="Available tabs">{options.length}</output> - <button onClick={() => setCurrentPluginID('plugin-1')}>select plugin</button> + <button onClick={() => setSelectedItem({ type: 'builtinTool', id: 'builtin-1' })}> + select builtin tool + </button> + <button onClick={() => setSelectedItem({ type: 'plugin', id: 'plugin-1' })}> + select plugin + </button> </div> ) } @@ -62,7 +70,9 @@ describe('PluginPageContextProvider', () => { expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('1') }) - it('keeps the query-state tab and updates the current plugin id', () => { + it('keeps the query-state tab and replaces the selected item', async () => { + const user = userEvent.setup() + renderWithProviders( <PluginPageContextProvider> <Consumer /> @@ -70,9 +80,17 @@ describe('PluginPageContextProvider', () => { { enableMarketplace: true, searchParams: '?tab=discover' }, ) - fireEvent.click(screen.getByText('select plugin')) + await user.click(screen.getByRole('button', { name: 'select builtin tool' })) - expect(screen.getByRole('status', { name: 'Current plugin' })).toHaveTextContent('plugin-1') + expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent( + 'builtinTool:builtin-1', + ) + + await user.click(screen.getByRole('button', { name: 'select plugin' })) + + expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent( + 'plugin:plugin-1', + ) expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('2') }) }) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index 9f8a18453a1..c4b14947809 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -1,6 +1,8 @@ import type { PluginDetail } from '../../types' +import type { PluginPageSelection } from '../context' import type { Collection } from '@/app/components/tools/types' import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { getStepByStepTourTargetSelector, @@ -15,14 +17,18 @@ const mockState = vi.hoisted(() => ({ tags: [] as string[], searchQuery: '', }, - currentPluginID: undefined as string | undefined, + selectedItem: undefined as PluginPageSelection | undefined, })) +const mockContextSubscribers = vi.hoisted(() => new Set<() => void>()) const mockSystemFeatures = vi.hoisted(() => ({ enableMarketplace: true, })) const mockSetFilters = vi.fn() -const mockSetCurrentPluginID = vi.fn() +const mockSetSelectedItem = vi.fn((item?: PluginPageSelection) => { + mockState.selectedItem = item + mockContextSubscribers.forEach((subscriber) => subscriber()) +}) const mockLoadNextPage = vi.fn() const mockInvalidateInstalledPluginList = vi.fn() const mockRemoveFilteredInstalledPluginPageOnUnmount = vi.fn() @@ -55,22 +61,40 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('../context', () => ({ - usePluginPageContext: ( - selector: (value: { - filters: typeof mockState.filters - setFilters: typeof mockSetFilters - currentPluginID: string | undefined - setCurrentPluginID: typeof mockSetCurrentPluginID - }) => unknown, - ) => - selector({ - filters: mockState.filters, - setFilters: mockSetFilters, - currentPluginID: mockState.currentPluginID, - setCurrentPluginID: mockSetCurrentPluginID, - }), -})) +vi.mock('../context', async () => { + const { useSyncExternalStore } = await import('react') + + return { + usePluginPageContext: ( + selector: (value: { + filters: typeof mockState.filters + setFilters: typeof mockSetFilters + selectedItem: PluginPageSelection | undefined + setSelectedItem: typeof mockSetSelectedItem + }) => unknown, + ) => + useSyncExternalStore( + (subscriber) => { + mockContextSubscribers.add(subscriber) + return () => mockContextSubscribers.delete(subscriber) + }, + () => + selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + selectedItem: mockState.selectedItem, + setSelectedItem: mockSetSelectedItem, + }), + () => + selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + selectedItem: mockState.selectedItem, + setSelectedItem: mockSetSelectedItem, + }), + ), + } +}) vi.mock('../filter-management', () => ({ default: ({ @@ -140,13 +164,19 @@ vi.mock('../list', () => ({ }) => ( <div data-testid="plugin-list"> {pluginList.map((plugin, index) => ( - <div + <button + type="button" key={plugin.plugin_id} + aria-pressed={ + mockState.selectedItem?.type === 'plugin' && + mockState.selectedItem.id === plugin.plugin_id + } data-step-by-step-tour-target={index === 0 ? firstPluginTarget : undefined} data-testid="plugin-list-item" + onClick={() => mockSetSelectedItem({ type: 'plugin', id: plugin.plugin_id })} > {plugin.plugin_id} - </div> + </button> ))} {children} </div> @@ -250,13 +280,14 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ detail?: PluginDetail onHide: () => void onUpdate: () => void - }) => ( - <div data-testid="plugin-detail-panel"> - <span>{detail?.plugin_id ?? 'none'}</span> - <button onClick={onHide}>hide detail</button> - <button onClick={onUpdate}>refresh detail</button> - </div> - ), + }) => + detail ? ( + <div data-testid="plugin-detail-panel"> + <span>{detail.plugin_id}</span> + <button onClick={onHide}>hide detail</button> + <button onClick={onUpdate}>refresh detail</button> + </div> + ) : null, })) const createPlugin = ( @@ -324,7 +355,7 @@ describe('PluginsPanel', () => { }, ) mockState.filters = { categories: [], tags: [], searchQuery: '' } - mockState.currentPluginID = undefined + mockState.selectedItem = undefined mockUseInstalledPluginList.mockReturnValue({ data: { plugins: [] }, isLoading: false, @@ -544,6 +575,43 @@ describe('PluginsPanel', () => { expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument() }) + it('replaces the builtin tool detail when an installed plugin is selected', async () => { + vi.useRealTimers() + const user = userEvent.setup() + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin('tool-plugin', 'Tool Plugin', [], PluginCategoryEnum.tool), + ]) + mockUseInstalledPluginList.mockReturnValue({ + data: { + plugins: [], + builtin_tools: [createBuiltinTool('builtin-tool', 'Builtin Tool')], + }, + isLoading: false, + isFetching: false, + isLastPage: true, + loadNextPage: mockLoadNextPage, + }) + + render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.tool} />) + + const builtinToolCard = screen.getByRole('button', { name: 'builtin-tool' }) + const pluginCard = screen.getByRole('button', { name: 'tool-plugin' }) + + await user.click(builtinToolCard) + + expect(builtinToolCard).toHaveAttribute('aria-pressed', 'true') + expect(pluginCard).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('builtin-tool-detail')).toHaveTextContent('builtin-tool') + expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument() + + await user.click(pluginCard) + + expect(pluginCard).toHaveAttribute('aria-pressed', 'true') + expect(builtinToolCard).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('tool-plugin') + expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument() + }) + it('filters builtin tools with the tool integrations search query', () => { mockState.filters.searchQuery = 'alpha' mockUseInstalledPluginList.mockReturnValue({ @@ -898,7 +966,7 @@ describe('PluginsPanel', () => { }) it('renders the empty state and keeps the current plugin detail in sync', () => { - mockState.currentPluginID = 'beta-tool' + mockState.selectedItem = { type: 'plugin', id: 'beta-tool' } mockState.filters.searchQuery = 'missing' mockPluginListWithLatestVersion.mockReturnValue([createPlugin('beta-tool', 'Beta Tool')]) @@ -907,10 +975,10 @@ describe('PluginsPanel', () => { expect(screen.getByTestId('empty-state')).toBeInTheDocument() expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool') - fireEvent.click(screen.getByText('hide detail')) fireEvent.click(screen.getByText('refresh detail')) + fireEvent.click(screen.getByText('hide detail')) - expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined) + expect(mockSetSelectedItem).toHaveBeenCalledWith(undefined) expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx index 457ca4386a8..2985e8c68ea 100644 --- a/web/app/components/plugins/plugin-page/context-provider.tsx +++ b/web/app/components/plugins/plugin-page/context-provider.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import type { PluginPageTab } from './context' +import type { PluginPageSelection, PluginPageTab } from './context' import type { FilterState } from './filter-management' import { useSuspenseQuery } from '@tanstack/react-query' import { parseAsStringEnum, useQueryState } from 'nuqs' @@ -38,7 +38,7 @@ export const PluginPageContextProvider = ({ searchQuery: '', }, ) - const [currentPluginID, setCurrentPluginID] = useState<string | undefined>() + const [selectedItem, setSelectedItem] = useState<PluginPageSelection | undefined>() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), @@ -56,8 +56,8 @@ export const PluginPageContextProvider = ({ <PluginPageContext.Provider value={{ containerRef, - currentPluginID, - setCurrentPluginID, + selectedItem, + setSelectedItem, filters, setFilters, activeTab, diff --git a/web/app/components/plugins/plugin-page/context.ts b/web/app/components/plugins/plugin-page/context.ts index c90cd2893e6..b7eff1a691b 100644 --- a/web/app/components/plugins/plugin-page/context.ts +++ b/web/app/components/plugins/plugin-page/context.ts @@ -11,10 +11,14 @@ export type PluginPageTab = | (typeof PLUGIN_PAGE_TABS_MAP)[keyof typeof PLUGIN_PAGE_TABS_MAP] | (typeof PLUGIN_TYPE_SEARCH_MAP)[keyof typeof PLUGIN_TYPE_SEARCH_MAP] +export type PluginPageSelection = + | { type: 'builtinTool'; id: string } + | { type: 'plugin'; id: string } + type PluginPageContextValue = { containerRef: RefObject<HTMLDivElement | null> - currentPluginID: string | undefined - setCurrentPluginID: (pluginID?: string) => void + selectedItem: PluginPageSelection | undefined + setSelectedItem: (item?: PluginPageSelection) => void filters: FilterState setFilters: (filter: FilterState) => void activeTab: PluginPageTab @@ -26,8 +30,8 @@ const emptyContainerRef: RefObject<HTMLDivElement | null> = { current: null } export const PluginPageContext = createContext<PluginPageContextValue>({ containerRef: emptyContainerRef, - currentPluginID: undefined, - setCurrentPluginID: noop, + selectedItem: undefined, + setSelectedItem: noop, filters: { categories: [], tags: [], diff --git a/web/app/components/plugins/plugin-page/nav-operations.tsx b/web/app/components/plugins/plugin-page/nav-operations.tsx index 3624317de29..2c4ded2e8d8 100644 --- a/web/app/components/plugins/plugin-page/nav-operations.tsx +++ b/web/app/components/plugins/plugin-page/nav-operations.tsx @@ -75,10 +75,18 @@ type SubmitRequestDropdownProps = { dividerAfterFirst?: boolean } -export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) { +type SubmitRequestDropdownMenuProps = SubmitRequestDropdownProps & { + docLink: (path: DocPathWithoutLang) => string +} + +// Presentational dropdown. Callers that cannot use useDocLink() — standalone +// Marketplace SSR — pass a locale-composed docLink instead. +export function SubmitRequestDropdownMenu({ + dividerAfterFirst, + docLink, +}: SubmitRequestDropdownMenuProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) - const docLink = useDocLink() const options = getOptions(docLink) return ( @@ -112,3 +120,8 @@ export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdo </DropdownMenu> ) } + +export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) { + const docLink = useDocLink() + return <SubmitRequestDropdownMenu dividerAfterFirst={dividerAfterFirst} docLink={docLink} /> +} diff --git a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx index 1031fbf45e4..3d0f46513f9 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx @@ -43,8 +43,8 @@ type PluginsPanelResultsProps = { isLastPage: boolean keywords: string loadNextPage: () => void + onSelectBuiltinTool: (id: string) => void scrollAreaLabel?: string - setCurrentBuiltinToolID: (id: string) => void showCategoryEmptyState: boolean tagFilterValue: string[] } @@ -71,8 +71,8 @@ const PluginsPanelResults = ({ isLastPage, keywords, loadNextPage, + onSelectBuiltinTool, scrollAreaLabel, - setCurrentBuiltinToolID, showCategoryEmptyState, tagFilterValue, }: PluginsPanelResultsProps) => { @@ -152,7 +152,7 @@ const PluginsPanelResults = ({ data-step-by-step-tour-target={ filteredList.length === 0 && index === 0 ? firstBuiltinToolTarget : undefined } - onClick={() => setCurrentBuiltinToolID(collection.id)} + onClick={() => onSelectBuiltinTool(collection.id)} > <IntegrationsToolProviderCard collection={collection} diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index 70a2937fc13..411204145fb 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -6,7 +6,7 @@ import type { FilterState } from './filter-management' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' -import { useMemo, useRef, useState } from 'react' +import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { isSearchResultEmpty } from '@/app/components/base/search-input/search-state' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' @@ -130,9 +130,8 @@ const PluginsPanel = ({ INTEGRATION_PLUGIN_PAGE_SIZE, installedPluginFilters, ) - const currentPluginID = usePluginPageContext((v) => v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) - const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState<string | undefined>() + const selectedItem = usePluginPageContext((v) => v.selectedItem) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const containerRef = useRef<HTMLDivElement>(null) const { run: handleFilterChange } = useDebounceFn( @@ -186,18 +185,17 @@ const PluginsPanel = ({ sourceCount: categoryList.length + builtinTools.length, }) - const currentPluginDetail = useMemo(() => { - const detail = pluginListWithLatestVersion.find( - (plugin) => plugin.plugin_id === currentPluginID, - ) - return detail - }, [currentPluginID, pluginListWithLatestVersion]) + const currentPluginID = selectedItem?.type === 'plugin' ? selectedItem.id : undefined + const currentBuiltinToolID = selectedItem?.type === 'builtinTool' ? selectedItem.id : undefined + const currentPluginDetail = useMemo( + () => pluginListWithLatestVersion.find((plugin) => plugin.plugin_id === currentPluginID), + [currentPluginID, pluginListWithLatestVersion], + ) const currentBuiltinTool = useMemo(() => { return filteredBuiltinTools.find((collection) => collection.id === currentBuiltinToolID) }, [currentBuiltinToolID, filteredBuiltinTools]) - const handleHide = () => setCurrentPluginID(undefined) - const handleBuiltinToolHide = () => setCurrentBuiltinToolID(undefined) + const handleDetailHide = () => setSelectedItem(undefined) const hasToolMarketplacePanel = enableMarketplace && isToolIntegrationPage const categoryMarketplace = enableMarketplace && hasEmbeddedMarketplace ? fixedCategory : undefined @@ -284,7 +282,7 @@ const PluginsPanel = ({ keywords={filters.searchQuery} loadNextPage={loadNextPage} scrollAreaLabel={scrollAreaLabel} - setCurrentBuiltinToolID={setCurrentBuiltinToolID} + onSelectBuiltinTool={(id) => setSelectedItem({ type: 'builtinTool', id })} tagFilterValue={filters.tags} canDeletePlugin={canDeletePlugin} canUpdatePlugin={canUpdatePlugin} @@ -327,14 +325,14 @@ const PluginsPanel = ({ onUpdate={() => { invalidateInstalledPluginList(fixedCategory) }} - onHide={handleHide} + onHide={handleDetailHide} canDeletePlugin={canDeletePlugin} canUpdatePlugin={canUpdatePlugin} /> {currentBuiltinTool && !currentBuiltinTool.plugin_id && ( <ProviderDetail collection={currentBuiltinTool} - onHide={handleBuiltinToolHide} + onHide={handleDetailHide} onRefreshData={invalidateInstalledPluginList} /> )} diff --git a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts index 4bc90a1b9bb..f1d734768ee 100644 --- a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts +++ b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts @@ -14,7 +14,7 @@ import { useMarketplace } from '../hooks' const mockQueryMarketplaceCollectionsAndPlugins = vi.fn() const mockQueryPlugins = vi.fn() const mockQueryPluginsWithDebounced = vi.fn() -const mockResetPlugins = vi.fn() +const mockResetQueryParams = vi.fn() const mockFetchNextPage = vi.fn() const mockUseMarketplaceCollectionsAndPlugins = vi.fn() @@ -70,7 +70,7 @@ const setupHookMocks = (overrides?: { }) mockUseMarketplacePlugins.mockReturnValue({ plugins: overrides?.plugins, - resetPlugins: mockResetPlugins, + resetQueryParams: mockResetQueryParams, queryPlugins: mockQueryPlugins, queryPluginsWithDebounced: mockQueryPluginsWithDebounced, isLoading: overrides?.isPluginsLoading ?? false, @@ -125,7 +125,7 @@ describe('useMarketplace', () => { }) expect(mockQueryPluginsWithDebounced).not.toHaveBeenCalled() expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled() - expect(mockResetPlugins).not.toHaveBeenCalled() + expect(mockResetQueryParams).not.toHaveBeenCalled() }) it('should query plugins immediately when only tags are provided', async () => { @@ -163,7 +163,7 @@ describe('useMarketplace', () => { type: 'plugin', }) }) - expect(mockResetPlugins).toHaveBeenCalledTimes(1) + expect(mockResetQueryParams).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 1b692200c0b..2985e97dbc4 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -29,13 +29,13 @@ export const useMarketplace = ( } = useMarketplaceCollectionsAndPlugins() const { plugins, - resetPlugins, + resetQueryParams, queryPlugins, isLoading: isPluginsLoading, fetchNextPage, hasNextPage, page: pluginsPage, - } = useMarketplacePlugins() + } = useMarketplacePlugins(enabled) const searchPluginTextRef = useRef(searchPluginText) const filterPluginTagsRef = useRef(filterPluginTags) @@ -72,7 +72,7 @@ export const useMarketplace = ( exclude, type: 'plugin', }) - resetPlugins() + resetQueryParams() } } }, [ @@ -80,7 +80,7 @@ export const useMarketplace = ( filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, - resetPlugins, + resetQueryParams, exclude, enabled, isSuccess, diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx index d7a61199c20..77bd5373d67 100644 --- a/web/app/components/workflow/__tests__/custom-edge.spec.tsx +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from 'react' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Position } from 'reactflow' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import CustomEdge from '../custom-edge' import { BlockEnum, NodeRunningStatus } from '../types' +import { renderWorkflowComponent } from './workflow-test-env' const mockUseAvailableBlocks = vi.hoisted(() => vi.fn()) const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) @@ -38,6 +40,9 @@ vi.mock('reactflow', () => ({ Right: 'right', Left: 'left', }, + useStoreApi: () => ({ + getState: () => ({ getNodes: () => [] }), + }), })) vi.mock('../hooks/use-available-blocks', async (importOriginal) => { @@ -81,8 +86,10 @@ describe('CustomEdge', () => { }) }) - it('should render a gradient edge and its real insert-node trigger', () => { - render( + it('should render a gradient edge and hide the start tab from its insert-node selector', async () => { + const user = userEvent.setup() + + renderWorkflowComponent( <CustomEdge id="edge-1" source="source-node" @@ -130,10 +137,14 @@ describe('CustomEdge', () => { opacity: '0.7', zIndex: '1001', }) + + await user.click(addBlockTrigger) + + expect(screen.queryByRole('tab', { name: 'workflow.tabs.start' })).not.toBeInTheDocument() }) it('should prefer the running stroke color when the edge is selected', () => { - render( + renderWorkflowComponent( <CustomEdge id="edge-selected" source="source-node" @@ -163,7 +174,7 @@ describe('CustomEdge', () => { }) it('should use the fail-branch running color while the connected node is hovering', () => { - render( + renderWorkflowComponent( <CustomEdge id="edge-hover" source="source-node" @@ -193,7 +204,7 @@ describe('CustomEdge', () => { }) it('should fall back to the default edge color when no highlight state is active', () => { - render( + renderWorkflowComponent( <CustomEdge id="edge-default" source="source-node" diff --git a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts index a237f6df114..df354ccf3f0 100644 --- a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts @@ -145,7 +145,7 @@ describe('useAvailableBlocks', () => { }) describe('inContainer filtering', () => { - it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => { + it('should allow HumanInput while excluding unsupported blocks when inContainer=true', () => { const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), { hooksStoreProps, }) @@ -155,7 +155,7 @@ describe('useAvailableBlocks', () => { expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource) expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase) - expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput) + expect(result.current.availableNextBlocks).toContain(BlockEnum.HumanInput) }) it('should exclude LoopEnd when not in container', () => { diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index 0eab5ad8af2..2d2353b6971 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -1183,15 +1183,14 @@ describe('useNodesInteractions', () => { ) }) - // Nested container paste restrictions should stay aligned with available block filtering. - describe('nested container paste restrictions', () => { + // Nested container paste behavior should stay aligned with available block filtering. + describe('nested container paste behavior', () => { const disallowedNestedPasteNodeTypes = [ BlockEnum.End, BlockEnum.Iteration, BlockEnum.Loop, BlockEnum.DataSource, BlockEnum.KnowledgeBase, - BlockEnum.HumanInput, ] const createNodeMeta = (type: BlockEnum) => ({ @@ -1205,7 +1204,7 @@ describe('useNodesInteractions', () => { }, }) - const runDisallowedPasteScenario = async ( + const pasteNodeIntoContainer = async ( containerType: BlockEnum.Iteration | BlockEnum.Loop, nodeType: BlockEnum, ) => { @@ -1263,23 +1262,48 @@ describe('useNodesInteractions', () => { const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] - expect(pastedNodes).toHaveLength(1) - expect(pastedNodes[0]?.id).toBe(containerId) - expect(pastedNodes[0]?.data._children).toEqual([]) - expect( - pastedNodes.some((node) => node.data.type === nodeType && node.parentId === containerId), - ).toBe(false) + return { containerId, pastedNodes } } it.each(disallowedNestedPasteNodeTypes)( 'should not paste %s into an iteration container', async (nodeType) => { - await runDisallowedPasteScenario(BlockEnum.Iteration, nodeType) + const { containerId, pastedNodes } = await pasteNodeIntoContainer( + BlockEnum.Iteration, + nodeType, + ) + + expect(pastedNodes).toHaveLength(1) + expect(pastedNodes[0]?.id).toBe(containerId) + expect(pastedNodes[0]?.data._children).toEqual([]) }, ) - it('should not paste human-input into a loop container', async () => { - await runDisallowedPasteScenario(BlockEnum.Loop, BlockEnum.HumanInput) - }) + it.each([BlockEnum.Iteration, BlockEnum.Loop] as const)( + 'should paste human-input into a %s container', + async (containerType) => { + const { containerId, pastedNodes } = await pasteNodeIntoContainer( + containerType, + BlockEnum.HumanInput, + ) + const container = pastedNodes.find((node) => node.id === containerId) + const pastedHumanInput = pastedNodes.find( + (node) => node.data.type === BlockEnum.HumanInput && node.parentId === containerId, + ) + const isIteration = containerType === BlockEnum.Iteration + + expect(pastedHumanInput).toBeDefined() + expect(pastedHumanInput?.data).toMatchObject({ + isInIteration: isIteration, + iteration_id: isIteration ? containerId : undefined, + isInLoop: !isIteration, + loop_id: isIteration ? undefined : containerId, + }) + expect(container?.data._children).toContainEqual({ + nodeId: pastedHumanInput?.id, + nodeType: BlockEnum.HumanInput, + }) + }, + ) }) }) diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts index 675a36be49a..6ebb1933f28 100644 --- a/web/app/components/workflow/hooks/use-available-blocks.ts +++ b/web/app/components/workflow/hooks/use-available-blocks.ts @@ -11,8 +11,7 @@ const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => { nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || - nodeType === BlockEnum.KnowledgeBase || - nodeType === BlockEnum.HumanInput) + nodeType === BlockEnum.KnowledgeBase) ) return false diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 4b2ea7bf6a8..6d38ddf853f 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1786,7 +1786,6 @@ export const useNodesInteractions = () => { BlockEnum.Loop, BlockEnum.DataSource, BlockEnum.KnowledgeBase, - BlockEnum.HumanInput, ] // Same-canvas copy keeps the source container selected, so only treat a // selected container as the paste target when it is not part of the clipboard. diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx index 490f656f284..b659664ddfb 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import type { CommonNodeType } from '@/app/components/workflow/types' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { BlockEnum } from '@/app/components/workflow/types' import { NodeSourceHandle, NodeTargetHandle } from '../node-handle' @@ -210,6 +211,16 @@ describe('node-handle', () => { // Target-side tests cover selector visibility, connection locking, and status rendering. describe('NodeTargetHandle', () => { + it('should show the start tab when adding a node before the target node', async () => { + const user = userEvent.setup() + + renderTargetHandle() + + await user.click(screen.getByTestId('handle-target-handle')) + + expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toBeInTheDocument() + }) + it('should toggle the target add trigger', () => { renderTargetHandle() @@ -260,6 +271,16 @@ describe('node-handle', () => { // Source-side tests cover selector opening paths, previous-node selection, and status styling. describe('NodeSourceHandle', () => { + it('should show the start tab when adding a node after the source node', async () => { + const user = userEvent.setup() + + renderSourceHandle() + + await user.click(screen.getByTestId('handle-source-handle')) + + expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toBeInTheDocument() + }) + it('should toggle the source add trigger', () => { renderSourceHandle() diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 955e5eb2f87..30a22ab512c 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -79,6 +79,7 @@ export const NodeTargetHandle = memo( 'z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', 'after:absolute after:top-1 after:left-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', + open && 'scale-125', data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle', data._runningStatus === NodeRunningStatus.Failed && @@ -106,6 +107,7 @@ export const NodeTargetHandle = memo( nextNodeTargetHandle: handleId, }} placement="left" + showStartTab triggerClassName={` absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} @@ -206,6 +208,7 @@ export const NodeSourceHandle = memo( 'group/handle z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!', 'after:absolute after:top-1 after:right-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', + open && 'scale-125', data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle', data._runningStatus === NodeRunningStatus.Failed && @@ -252,6 +255,7 @@ export const NodeSourceHandle = memo( data-popup-open:opacity-100 `} availableBlocksTypes={availableNextBlocks} + showStartTab /> )} </Handle> diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 4cface45094..d53e1150749 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -263,30 +263,11 @@ vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({ onSaved, }: { open: boolean - onSaved: (binding: { - agent_id?: string | null - binding_type: 'inline_agent' | 'roster_agent' - current_snapshot_id?: string | null - id: string - node_id: string - workflow_id: string - }) => void + onSaved: (agentId: string) => void }) => open ? ( <div role="dialog" aria-label="save-inline-agent-to-roster"> - <button - type="button" - onClick={() => - onSaved({ - id: 'binding-1', - binding_type: 'roster_agent', - agent_id: 'saved-roster-agent', - current_snapshot_id: 'saved-snapshot', - workflow_id: 'workflow-1', - node_id: 'agent-node', - }) - } - > + <button type="button" onClick={() => onSaved('saved-roster-agent')}> Save inline agent to roster </button> </div> diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx index 97a0ff5323c..2e6a40f8560 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx @@ -1,5 +1,5 @@ import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FlowType } from '@/types/common' import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog' @@ -112,7 +112,6 @@ const renderDialog = (agent: AgentComposerAgentResponse = inlineAgent) => { <SaveInlineAgentToRosterDialog flowId="app-1" flowType={FlowType.appFlow} - formKey={1} initialAgent={agent} nodeId="node-1" open @@ -182,7 +181,6 @@ describe('SaveInlineAgentToRosterDialog', () => { <SaveInlineAgentToRosterDialog flowId="snippet-1" flowType={FlowType.snippet} - formKey={1} initialAgent={inlineAgent} nodeId="node-1" open @@ -295,4 +293,122 @@ describe('SaveInlineAgentToRosterDialog', () => { }), ) }) + + it('keeps one source snapshot while open and uses the latest agent after reopening', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const onSaved = vi.fn() + const updatedInlineAgent = { + ...inlineAgent, + description: 'Updated source description.', + icon: '🦊', + icon_background: '#FFEDD5', + role: 'Updated source role', + } + const { rerender } = render( + <SaveInlineAgentToRosterDialog + flowId="app-1" + flowType={FlowType.appFlow} + initialAgent={inlineAgent} + nodeId="node-1" + open + onOpenChange={onOpenChange} + onSaved={onSaved} + />, + ) + + rerender( + <SaveInlineAgentToRosterDialog + flowId="app-1" + flowType={FlowType.appFlow} + initialAgent={updatedInlineAgent} + nodeId="node-1" + open + onOpenChange={onOpenChange} + onSaved={onSaved} + />, + ) + + let dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Tender Analyst') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.saveToRosterForm.changeIcon', + }), + ) + expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument() + + rerender( + <SaveInlineAgentToRosterDialog + flowId="app-1" + flowType={FlowType.appFlow} + initialAgent={updatedInlineAgent} + nodeId="node-1" + open={false} + onOpenChange={onOpenChange} + onSaved={onSaved} + />, + ) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender( + <SaveInlineAgentToRosterDialog + flowId="app-1" + flowType={FlowType.appFlow} + initialAgent={updatedInlineAgent} + nodeId="node-1" + open + onOpenChange={onOpenChange} + onSaved={onSaved} + />, + ) + dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Updated source role') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.saveToRosterForm.changeIcon', + }), + ) + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('returns only the saved roster agent id after a successful save', async () => { + const user = userEvent.setup() + const { onOpenChange, onSaved } = renderDialog() + + const dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Roster Tender Agent', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] + mutationOptions.onSuccess({ + binding: { + agent_id: 'roster-agent-1', + binding_type: 'roster_agent', + }, + }) + + expect(onSaved).toHaveBeenCalledWith('roster-agent-1') + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(toastMock.success).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx index 318d9be15cb..69f4f72b89d 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx @@ -1,9 +1,9 @@ 'use client' import type { AgentComposerAgentResponse, - AgentComposerBindingResponse, WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' +import type { Ref } from 'react' import type { AgentFormValues, AgentIconSelection, @@ -18,34 +18,100 @@ import { } from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' -import { - createAgentIconSelection, - defaultAgentIcon, -} from '@/features/agent-v2/roster/components/agent-form' +import { createAgentIconSelection } from '@/features/agent-v2/roster/components/agent-form' import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' type SaveInlineAgentToRosterDialogProps = { - flowId?: string - flowType?: FlowType - formKey: number - initialAgent?: AgentComposerAgentResponse | null + flowId: string + flowType: FlowType.appFlow | FlowType.snippet + initialAgent: AgentComposerAgentResponse nodeId: string open: boolean onOpenChange: (open: boolean) => void - onSaved: (binding: AgentComposerBindingResponse) => void + onSaved: (agentId: string) => void +} + +type SaveInlineAgentToRosterFormSessionProps = { + initialAgent: AgentComposerAgentResponse + nameInputRef: Ref<HTMLInputElement> + pending: boolean + onCancel: () => void + onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void +} + +function SaveInlineAgentToRosterFormSession({ + initialAgent, + nameInputRef, + pending, + onCancel, + onSubmit, +}: SaveInlineAgentToRosterFormSessionProps) { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const [initialValues] = useState(() => ({ + fields: { + description: initialAgent.description ?? '', + name: '', + role: initialAgent.role ?? '', + } satisfies AgentFormValues, + icon: createAgentIconSelection(initialAgent), + })) + const [agentIcon, setAgentIcon] = useState(initialValues.icon) + const [iconPickerOpen, setIconPickerOpen] = useState(false) + + return ( + <> + <div className="shrink-0 ps-6 pe-14 pt-6 pb-3"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['roster.saveToRosterDialog.title'])} + </DialogTitle> + <DialogDescription className="sr-only"> + {t(($) => $['roster.saveToRosterDialog.description'])} + </DialogDescription> + </div> + <Form<AgentFormValues> + className="flex min-h-0 flex-1 flex-col" + onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)} + > + <AgentFormFields + ref={nameInputRef} + defaultValues={initialValues.fields} + icon={agentIcon} + iconAriaLabel={t(($) => $['roster.saveToRosterForm.changeIcon'])} + onIconClick={() => setIconPickerOpen(true)} + /> + <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> + <Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}> + {tCommon(($) => $['operation.cancel'])} + </Button> + <Button type="submit" variant="primary" className="min-w-18" loading={pending}> + {tCommon(($) => $['operation.save'])} + </Button> + </div> + </Form> + <AppIconPicker + open={iconPickerOpen} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } + onOpenChange={setIconPickerOpen} + onSelect={setAgentIcon} + /> + </> + ) } export function SaveInlineAgentToRosterDialog({ flowId, flowType, - formKey, initialAgent, nodeId, open, @@ -53,14 +119,7 @@ export function SaveInlineAgentToRosterDialog({ onSaved, }: SaveInlineAgentToRosterDialogProps) { const { t } = useTranslation('agentV2') - const { t: tCommon } = useTranslation('common') - const [name, setName] = useState('') - const [description, setDescription] = useState(initialAgent?.description ?? '') - const [role, setRole] = useState(initialAgent?.role ?? '') - const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => - initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon, - ) + const nameInputRef = useRef<HTMLInputElement>(null) const appSaveToRosterMutation = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(), ) @@ -69,33 +128,20 @@ export function SaveInlineAgentToRosterDialog({ ) const isSavingToRoster = appSaveToRosterMutation.isPending || snippetSaveToRosterMutation.isPending - const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen) { - setName('') - setDescription(initialAgent?.description ?? '') - setRole(initialAgent?.role ?? '') - setAgentIcon(initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon) - } else { - setIconPickerOpen(false) - } + if (!nextOpen && isSavingToRoster) return onOpenChange(nextOpen) } - const handleSubmit = (formValues: AgentFormValues) => { + const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => { if (isSavingToRoster) return - if (!flowId) return - - const trimmedName = formValues.name?.trim() ?? '' - const trimmedRole = formValues.role?.trim() ?? '' - const body = { variant: 'workflow' as const, save_strategy: 'save_to_roster' as const, - new_agent_name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, + new_agent_name: formValues.name.trim(), + description: formValues.description.trim(), + role: formValues.role.trim(), icon_type: agentIcon.type, icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, @@ -105,9 +151,8 @@ export function SaveInlineAgentToRosterDialog({ const binding = composerState.binding if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return - toast.success(t(($) => $['roster.saveToRosterSuccess'])) - onSaved(binding) - handleOpenChange(false) + onSaved(binding.agent_id) + onOpenChange(false) }, } @@ -142,75 +187,31 @@ export function SaveInlineAgentToRosterDialog({ return ( <> <Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal> - <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"> + <DialogContent + initialFocus={nameInputRef} + className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!" + > <DialogClose + disabled={isSavingToRoster} render={ <IconButton aria-label={t(($) => $['operation.close'], { ns: 'common' })} size="lg" - className="absolute inset-e-6 top-6" + className="absolute inset-e-5 top-5" > <span aria-hidden className="i-ri-close-line size-4" /> </IconButton> } /> - <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> - <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t(($) => $['roster.saveToRosterDialog.title'])} - </DialogTitle> - <DialogDescription className="sr-only"> - {t(($) => $['roster.saveToRosterDialog.description'])} - </DialogDescription> - </div> - <Form<AgentFormValues> - key={formKey} - className="min-h-0 flex-1" - onFormSubmit={handleSubmit} - > - <AgentFormFields - description={description} - icon={agentIcon} - iconAriaLabel={t(($) => $['roster.saveToRosterForm.changeIcon'])} - name={name} - role={role} - onDescriptionChange={setDescription} - onIconClick={() => setIconPickerOpen(true)} - onNameChange={setName} - onRoleChange={setRole} - /> - <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button - type="button" - className="min-w-18" - onClick={() => handleOpenChange(false)} - disabled={isSavingToRoster} - > - {tCommon(($) => $['operation.cancel'])} - </Button> - <Button - type="submit" - variant="primary" - className="min-w-18" - loading={isSavingToRoster} - > - {tCommon(($) => $['operation.save'])} - </Button> - </div> - </Form> + <SaveInlineAgentToRosterFormSession + initialAgent={initialAgent} + nameInputRef={nameInputRef} + pending={isSavingToRoster} + onCancel={() => onOpenChange(false)} + onSubmit={handleSubmit} + /> </DialogContent> </Dialog> - <AppIconPicker - open={iconPickerOpen} - initialEmoji={ - agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined - } - onOpenChange={setIconPickerOpen} - onSelect={(icon) => { - setAgentIcon(icon) - }} - /> </> ) } diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index ee5c6b9e6b5..1c12d645377 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -131,7 +131,6 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) { requestKey: number } | null>(null) const [isOutputVariablesCollapsed, setIsOutputVariablesCollapsed] = useState(true) - const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0) const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId) const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId) @@ -186,7 +185,12 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) { const isAgentBindingPending = isInlineAgentPending || isInlineAgentWaitingForCreation || isCreatingInlineAgent const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent' - const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent + const saveToRosterTarget = + configsMap?.flowId && + (configsMap.flowType === FlowType.appFlow || configsMap.flowType === FlowType.snippet) + ? { flowId: configsMap.flowId, flowType: configsMap.flowType } + : null + const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent && !!saveToRosterTarget const inlineComposerStateForPanel = inlineAgentQuery.data const displayedAgent = rosterAgentQuery.data ?? @@ -378,14 +382,11 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) { ]) const handleSaveInlineToRosterOpen = useCallback(() => { - setSaveToRosterSessionKey((key) => key + 1) setIsSaveToRosterDialogOpen(true) }, []) const handleInlineSavedToRoster = useCallback( - (binding: AgentComposerBindingResponse) => { - if (binding.binding_type !== 'roster_agent' || !binding.agent_id) return - + (agentId: string) => { setOpenInlineAgentPanelNodeId(undefined) setIsInlineAgentPanelOpenedFromTrigger(false) setIsRosterAgentPanelOpen(true) @@ -395,7 +396,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) { delete draft._openInlineAgentPanel draft.agent_binding = { binding_type: 'roster_agent', - agent_id: binding.agent_id!, + agent_id: agentId, } }) inputsRef.current = newInputs @@ -698,17 +699,17 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) { onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined} onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined} /> - <SaveInlineAgentToRosterDialog - key={saveToRosterSessionKey} - flowId={configsMap?.flowId} - flowType={configsMap?.flowType} - formKey={saveToRosterSessionKey} - initialAgent={inlineAgent} - nodeId={id} - open={isSaveToRosterDialogOpen} - onOpenChange={setIsSaveToRosterDialogOpen} - onSaved={handleInlineSavedToRoster} - /> + {saveToRosterTarget && inlineAgent && ( + <SaveInlineAgentToRosterDialog + flowId={saveToRosterTarget.flowId} + flowType={saveToRosterTarget.flowType} + initialAgent={inlineAgent} + nodeId={id} + open={isSaveToRosterDialogOpen} + onOpenChange={setIsSaveToRosterDialogOpen} + onSaved={handleInlineSavedToRoster} + /> + )} </div> <div aria-disabled={isInlineAgentPending} diff --git a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts index 6457e90e8c0..e4671dc5c83 100644 --- a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts +++ b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts @@ -48,6 +48,18 @@ describe('post-login redirect utilities', () => { ).toEqual({ kind: 'absolute', href: redirectUrl }) }) + it('should reject a Marketplace origin that is not a trusted Dify login target', () => { + const searchParams = new URLSearchParams({ + redirect_url: 'http://localhost:3001/plugin/langgenius/openai?tab=reviews#rating', + }) + + expect( + resolvePostLoginRedirect( + searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0], + ), + ).toEqual({ kind: 'internal', href: '/' }) + }) + it('should use the default target instead of a stored device target when the query target is invalid', () => { setPostLoginRedirect('/device?user_code=ABCD') const searchParams = new URLSearchParams({ redirect_url: 'https://google.com' }) @@ -109,4 +121,15 @@ describe('post-login redirect utilities', () => { expect(resolvePostLoginRedirect()).toEqual({ kind: 'internal', href: '/' }) }) + + it('should preserve every Marketplace OAuth authorize parameter across signin', () => { + setPostLoginRedirect( + '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code', + ) + + expect(resolvePostLoginRedirect()).toEqual({ + kind: 'internal', + href: '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code', + }) + }) }) diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index 8d9991a51f0..fc4ecaaca43 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -7,7 +7,13 @@ const DEVICE_TTL_MS = 15 * 60 * 1000 const ALLOWED: Record<string, ReadonlySet<string>> = { '/device': new Set(['user_code', 'sso_verified']), - '/account/oauth/authorize': new Set(['client_id', 'scope', 'state', 'redirect_uri']), + '/account/oauth/authorize': new Set([ + 'client_id', + 'redirect_uri', + 'response_type', + 'scope', + 'state', + ]), } function validateDeviceRedirect(target: string): string | null { diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index 9e0e66bbbb1..3287a77cd5a 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -186,6 +186,8 @@ vi.mock('@/app/components/base/amplitude/use-amplitude-initialized', () => ({ vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({ flushRegistrationSuccess: vi.fn(), + subscribeRegistrationSuccess: () => () => {}, + getRegistrationSuccessSnapshot: () => 0, })) vi.mock('@/app/components/base/zendesk/utils', () => ({ diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx index d1fcfb76586..a04a731b2e8 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx @@ -471,7 +471,7 @@ function AgentVersionRestoreBar({ <Button type="button" variant="secondary" - className="h-8 rounded-lg px-3 text-text-accent" + className="h-8 shrink-0 rounded-lg px-3 text-text-accent" onClick={onExitVersions} > <span aria-hidden className="i-ri-arrow-go-back-line size-4 shrink-0" /> diff --git a/web/features/agent-v2/agent-detail/sidebar-actions.tsx b/web/features/agent-v2/agent-detail/sidebar-actions.tsx index 03385bfda6e..4e04ed08d54 100644 --- a/web/features/agent-v2/agent-detail/sidebar-actions.tsx +++ b/web/features/agent-v2/agent-detail/sidebar-actions.tsx @@ -1,6 +1,7 @@ 'use client' import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' +import type { AgentFormSource } from '@/features/agent-v2/roster/components/agent-form' import { DropdownMenu, DropdownMenuContent, @@ -17,50 +18,22 @@ import { DuplicateAgentDialog } from '@/features/agent-v2/roster/components/dupl import { EditAgentDialog } from '@/features/agent-v2/roster/components/edit-agent-dialog' import { useRouter } from '@/next/navigation' -type AgentDetailSidebarActionAgent = Pick< - AgentAppPartial, - | 'app_id' - | 'description' - | 'icon' - | 'icon_background' - | 'icon_type' - | 'icon_url' - | 'id' - | 'mode' - | 'name' - | 'role' -> +type AgentDetailSidebarActionAgent = AgentFormSource & Pick<AgentAppPartial, 'app_id'> export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebarActionAgent }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const { t: tApp } = useTranslation('app') const [isEditOpen, setIsEditOpen] = useState(false) - const [editSessionKey, setEditSessionKey] = useState(0) const [isDuplicateOpen, setIsDuplicateOpen] = useState(false) - const [duplicateSessionKey, setDuplicateSessionKey] = useState(0) const [isDeleteOpen, setIsDeleteOpen] = useState(false) const { exportAppDsl, isExporting } = useExportAppDsl() const router = useRouter() - const dialogAgent: AgentAppPartial = { - description: agent.description, - icon: agent.icon, - icon_background: agent.icon_background, - icon_type: agent.icon_type, - icon_url: agent.icon_url, - id: agent.id, - mode: agent.mode, - name: agent.name, - role: agent.role, - } - const handleEditOpen = () => { - setEditSessionKey((key) => key + 1) setIsEditOpen(true) } const handleDuplicateOpen = () => { - setDuplicateSessionKey((key) => key + 1) setIsDuplicateOpen(true) } @@ -112,15 +85,9 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> - <EditAgentDialog - key={editSessionKey} - agent={dialogAgent} - open={isEditOpen} - onOpenChange={setIsEditOpen} - /> + <EditAgentDialog agent={agent} open={isEditOpen} onOpenChange={setIsEditOpen} /> <DuplicateAgentDialog - key={duplicateSessionKey} - agent={dialogAgent} + agent={agent} open={isDuplicateOpen} onOpenChange={setIsDuplicateOpen} /> diff --git a/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts b/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts new file mode 100644 index 00000000000..d083bc288ae --- /dev/null +++ b/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts @@ -0,0 +1,17 @@ +import { createAgentIconSelection } from '../agent-form' + +describe('createAgentIconSelection', () => { + it('uses the resolved image URL while preserving the uploaded file id', () => { + expect( + createAgentIconSelection({ + icon: 'uploaded-file-id', + icon_type: 'image', + icon_url: 'https://example.com/resolved-agent-icon.png', + }), + ).toEqual({ + type: 'image', + fileId: 'uploaded-file-id', + url: 'https://example.com/resolved-agent-icon.png', + }) + }) +}) diff --git a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx index 7423faf5c41..4890cc5a745 100644 --- a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx @@ -112,7 +112,7 @@ describe('CreateAgentDialog', () => { mutationOptions.onSuccess({ id: 'agent-1' }) }) - expect(toastMock.success).toHaveBeenCalledWith('agentV2.roster.createSuccess') + expect(toastMock.success).not.toHaveBeenCalled() expect(trackCreateAppMock).toHaveBeenCalledWith({ source: 'studio_blank', appMode: 'agent-v2', @@ -141,6 +141,44 @@ describe('CreateAgentDialog', () => { expect(mutationMock.mutate).not.toHaveBeenCalled() }) + it('focuses the name field when opened and resets native form values after closing', async () => { + const user = userEvent.setup() + render(<CreateAgentDialog />) + + const trigger = screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ }) + await user.click(trigger) + + let dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) + const descriptionInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel/, + }) + expect(nameInput).toHaveFocus() + expect(descriptionInput).toHaveAttribute('maxlength', '400') + + await user.type(nameInput, 'Temporary Agent') + await user.type(descriptionInput, 'Temporary description') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: 'agentV2.roster.createDialog.title' }), + ).not.toBeInTheDocument() + }) + + await user.click(trigger) + dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('') + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel/, + }), + ).toHaveValue('') + }) + it('marks role and description as optional', async () => { const user = userEvent.setup() render(<CreateAgentDialog />) diff --git a/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx new file mode 100644 index 00000000000..6087ddb4e97 --- /dev/null +++ b/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx @@ -0,0 +1,151 @@ +import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DuplicateAgentDialog } from '../duplicate-agent-dialog' + +const queryDataMock = vi.hoisted(() => vi.fn()) +const mutationMock = vi.hoisted(() => ({ + isPending: false, + mutate: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => mutationMock, + useQueryClient: () => ({ + getQueryData: queryDataMock, + }), +})) + +vi.mock('@/app/components/base/app-icon-picker', () => ({ + __esModule: true, + default: ({ + initialEmoji, + open, + }: { + initialEmoji?: { icon: string; background: string } + open: boolean + }) => (open ? <span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span> : null), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + agent: { + byAgentId: { + copy: { + post: { + mutationOptions: vi.fn(() => ({})), + }, + }, + get: { + queryKey: vi.fn(() => ['agent']), + }, + }, + }, + }, +})) + +const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial => ({ + description: 'Original description', + icon: '🧸', + icon_background: '#F5F3FF', + icon_type: 'emoji', + icon_url: null, + id: 'agent-1', + mode: 'agent', + name: 'Research Agent', + role: 'Research Assistant', + ...overrides, +}) + +describe('DuplicateAgentDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mutationMock.isPending = false + queryDataMock.mockReturnValue(undefined) + }) + + it('keeps one form snapshot while open and creates a new session after closing', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const updatedAgent = createAgent({ + icon: '🦊', + icon_background: '#FFEDD5', + name: 'Updated Agent', + role: 'Updated Role', + }) + const { rerender } = render( + <DuplicateAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />, + ) + + rerender(<DuplicateAgentDialog agent={updatedAgent} open onOpenChange={onOpenChange} />) + + let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Research Agent copy') + await user.click( + within(dialog).getByRole('button', { + name: /agentV2\.roster\.duplicateForm\.changeIcon.*Research Agent/, + }), + ) + expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument() + + rerender(<DuplicateAgentDialog agent={updatedAgent} open={false} onOpenChange={onOpenChange} />) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender(<DuplicateAgentDialog agent={updatedAgent} open onOpenChange={onOpenChange} />) + dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Updated Agent copy') + await user.click( + within(dialog).getByRole('button', { + name: /agentV2\.roster\.duplicateForm\.changeIcon.*Updated Agent/, + }), + ) + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('starts a new form session when the agent identity changes', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const secondAgent = createAgent({ + description: 'Second description', + id: 'agent-2', + name: 'Second Agent', + role: 'Second Role', + }) + const { rerender } = render( + <DuplicateAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />, + ) + + rerender(<DuplicateAgentDialog agent={secondAgent} open onOpenChange={onOpenChange} />) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Second Agent copy') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.duplicate' })) + + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-2', + }, + body: { + name: 'Second Agent copy', + description: 'Second description', + role: 'Second Role', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) +}) diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx index 8cee962675c..632e1fce60a 100644 --- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx @@ -1,5 +1,5 @@ import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { EditAgentDialog } from '../edit-agent-dialog' @@ -27,19 +27,24 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@/app/components/base/app-icon-picker', () => ({ __esModule: true, default: ({ + initialEmoji, onSelect, open, }: { + initialEmoji?: { icon: string; background: string } onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void open: boolean }) => open ? ( - <button - type="button" - onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })} - > - Select brain icon - </button> + <div> + <span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span> + <button + type="button" + onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })} + > + Select brain icon + </button> + </div> ) : null, })) @@ -71,9 +76,9 @@ const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial const renderDialog = (agent = createAgent()) => { const onOpenChange = vi.fn() - render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />) + const renderResult = render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />) - return { onOpenChange } + return { ...renderResult, onOpenChange } } describe('EditAgentDialog', () => { @@ -154,12 +159,35 @@ describe('EditAgentDialog', () => { expect(mutationOptions).not.toHaveProperty('onError') }) + it('closes without a redundant success toast after updating', async () => { + const user = userEvent.setup() + const { onOpenChange } = renderDialog() + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + const roleInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel/, + }) + await user.clear(roleInput) + await user.type(roleInput, 'Market Analyst') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] + mutationOptions.onSuccess() + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(toastMock.success).not.toHaveBeenCalled() + }) + it('submits selected icon fields when the roster icon changes', async () => { const user = userEvent.setup() renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.click(within(dialog).getByRole('button', { name: /agentV2\.roster\.editAgent/ })) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) @@ -185,6 +213,159 @@ describe('EditAgentDialog', () => { expect(mutationOptions).not.toHaveProperty('onError') }) + it('keeps the original form snapshot when the agent source changes while open', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const agent = createAgent() + const { rerender } = render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />) + + let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + + rerender( + <EditAgentDialog + agent={createAgent({ icon: '🦊', icon_background: '#FFEDD5' })} + open + onOpenChange={onOpenChange} + />, + ) + + dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Research Agent') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument() + }) + + it('keeps a user-selected icon when the agent source changes during the session', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { rerender } = render( + <EditAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />, + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) + + rerender( + <EditAgentDialog + agent={createAgent({ icon: '🦊', icon_background: '#FFEDD5' })} + open + onOpenChange={onOpenChange} + />, + ) + + expect(screen.getByText('🧠:#E0F2FE')).toBeInTheDocument() + expect( + within(screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })).getByRole( + 'button', + { name: 'common.operation.save' }, + ), + ).not.toBeDisabled() + }) + + it('creates a fresh form session from the latest agent after closing', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const agent = createAgent() + const { rerender } = render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) + + rerender(<EditAgentDialog agent={agent} open={false} onOpenChange={onOpenChange} />) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender( + <EditAgentDialog + agent={createAgent({ icon: '🦊', icon_background: '#FFEDD5' })} + open + onOpenChange={onOpenChange} + />, + ) + const reopenedDialog = screen.getByRole('dialog', { + name: 'agentV2.roster.editDialog.title', + }) + await user.click( + within(reopenedDialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('starts a new form session when the agent identity changes', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { rerender } = render( + <EditAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />, + ) + + rerender( + <EditAgentDialog + agent={createAgent({ + description: 'Second description', + icon: '🦊', + icon_background: '#FFEDD5', + id: 'agent-2', + name: 'Second Agent', + role: 'Second Role', + })} + open + onOpenChange={onOpenChange} + />, + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) + expect(nameInput).toHaveValue('Second Agent') + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'Renamed Second Agent') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-2', + }, + body: { + name: 'Renamed Second Agent', + description: 'Second description', + role: 'Second Role', + icon_type: 'emoji', + icon: '🦊', + icon_background: '#FFEDD5', + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + it('shows a field error when saving with an empty name', async () => { const user = userEvent.setup() renderDialog() diff --git a/web/features/agent-v2/roster/components/agent-form-fields.tsx b/web/features/agent-v2/roster/components/agent-form-fields.tsx index ff1b5caaf03..7203f173256 100644 --- a/web/features/agent-v2/roster/components/agent-form-fields.tsx +++ b/web/features/agent-v2/roster/components/agent-form-fields.tsx @@ -1,4 +1,5 @@ -import type { AgentIconSelection } from './agent-form' +import type { Ref } from 'react' +import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -6,34 +7,26 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' type AgentFormFieldsProps = { - description: string + defaultValues: AgentFormValues icon: AgentIconSelection iconAriaLabel: string - name: string - onDescriptionChange: (description: string) => void onIconClick: () => void - onNameChange: (name: string) => void - onRoleChange: (role: string) => void - role: string + ref: Ref<HTMLInputElement> } export function AgentFormFields({ - description, + defaultValues, icon, iconAriaLabel, - name, - onDescriptionChange, onIconClick, - onNameChange, - onRoleChange, - role, + ref, }: AgentFormFieldsProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') return ( - <div className="space-y-5 px-6 py-3"> - <div className="flex items-end gap-4 pb-2"> + <div className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain px-6 py-3"> + <div className="flex items-start gap-4"> <button type="button" aria-label={iconAriaLabel} @@ -50,10 +43,10 @@ export function AgentFormFields({ imageUrl={icon.type === 'emoji' ? undefined : icon.url} /> </button> - <div className="flex min-w-0 flex-1 gap-3 pb-1"> + <div className="flex min-w-0 flex-1 flex-col items-start gap-3 pb-1 sm:flex-row"> <Field name="name" - className="relative min-w-0 flex-1" + className="min-w-0 flex-1" validate={(value) => { if (typeof value === 'string' && value.length > 0 && !value.trim()) return t(($) => $['roster.createForm.nameRequired']) @@ -63,23 +56,19 @@ export function AgentFormFields({ > <FieldLabel>{t(($) => $['roster.createForm.nameLabel'])}</FieldLabel> <Input + ref={ref} autoComplete="off" - // oxlint-disable-next-line jsx-a11y/no-autofocus -- Agent roster dialogs open from explicit commands, and the name field is the primary editable control. - autoFocus + defaultValue={defaultValues.name} maxLength={255} - onValueChange={onNameChange} placeholder={t(($) => $['roster.createForm.namePlaceholder'])} required - value={name} /> - <div className="absolute top-full left-0 mt-1"> - <FieldError match="valueMissing"> - {t(($) => $['roster.createForm.nameRequired'])} - </FieldError> - <FieldError match="customError" /> - </div> + <FieldError match="valueMissing"> + {t(($) => $['roster.createForm.nameRequired'])} + </FieldError> + <FieldError match="customError" /> </Field> - <Field name="role" className="relative min-w-0 flex-1"> + <Field name="role" className="min-w-0 flex-1"> <FieldLabel> {t(($) => $['roster.createForm.roleLabel'])} <span className="ml-1 system-xs-regular text-text-tertiary"> @@ -88,10 +77,9 @@ export function AgentFormFields({ </FieldLabel> <Input autoComplete="off" + defaultValue={defaultValues.role} maxLength={255} - onValueChange={onRoleChange} placeholder={t(($) => $['roster.createForm.rolePlaceholder'])} - value={role} /> </Field> </div> @@ -106,9 +94,9 @@ export function AgentFormFields({ <Textarea autoComplete="off" className="h-20 resize-none" - onValueChange={onDescriptionChange} + defaultValue={defaultValues.description} + maxLength={400} placeholder={t(($) => $['roster.createForm.descriptionPlaceholder'])} - value={description} /> </Field> </div> diff --git a/web/features/agent-v2/roster/components/agent-form.ts b/web/features/agent-v2/roster/components/agent-form.ts index f726759d5dd..89c140fe4f6 100644 --- a/web/features/agent-v2/roster/components/agent-form.ts +++ b/web/features/agent-v2/roster/components/agent-form.ts @@ -1,11 +1,20 @@ +import type { + AgentAppCreatePayload, + AgentAppPartial, +} from '@dify/contracts/api/console/agent/types.gen' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +type AgentFormField = 'description' | 'name' | 'role' + export type AgentFormValues = { - description?: string - name?: string - role?: string + [Field in AgentFormField]-?: NonNullable<AgentAppCreatePayload[Field]> } +export type AgentFormSource = Pick< + AgentAppPartial, + 'description' | 'icon' | 'icon_background' | 'icon_type' | 'icon_url' | 'id' | 'name' | 'role' +> + export type AgentIconSelection = | AppIconSelection | { @@ -24,6 +33,7 @@ type AgentIconSource = { icon?: string | null icon_background?: string | null icon_type?: string | null + icon_url?: string | null } export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelection => { @@ -31,7 +41,7 @@ export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelec return { type: 'image', fileId: agent.icon, - url: agent.icon, + url: agent.icon_url ?? agent.icon, } } diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 64184d7b599..68847ce4c94 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -205,8 +205,6 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const nameId = useId() const descriptionId = useId() const [activeDialog, setActiveDialog] = useState<'delete' | 'duplicate' | 'edit' | null>(null) - const [editSessionKey, setEditSessionKey] = useState(0) - const [duplicateSessionKey, setDuplicateSessionKey] = useState(0) const { exportAppDsl, isExporting } = useExportAppDsl() const updatedAt = agent.updated_at != null @@ -220,16 +218,19 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const hasPublishedReferences = publishedReferences.length > 0 const isDraft = agent.active_config_is_published !== true const parsedIconType = zAgentIconType.safeParse(agent.icon_type).data - const imageUrl = parsedIconType === 'image' || parsedIconType === 'link' ? agent.icon : undefined + const imageUrl = + parsedIconType === 'image' + ? (agent.icon_url ?? agent.icon) + : parsedIconType === 'link' + ? agent.icon + : undefined const iconType = parsedIconType === 'link' ? 'image' : parsedIconType const handleEditOpen = () => { - setEditSessionKey((key) => key + 1) setActiveDialog('edit') } const handleDuplicateOpen = () => { - setDuplicateSessionKey((key) => key + 1) setActiveDialog('duplicate') } @@ -370,13 +371,11 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { </div> </div> <EditAgentDialog - key={editSessionKey} agent={agent} open={activeDialog === 'edit'} onOpenChange={handleDialogOpenChange} /> <DuplicateAgentDialog - key={duplicateSessionKey} agent={agent} open={activeDialog === 'duplicate'} onOpenChange={handleDialogOpenChange} diff --git a/web/features/agent-v2/roster/components/create-agent-dialog.tsx b/web/features/agent-v2/roster/components/create-agent-dialog.tsx index a9b2616c40a..149dc830677 100644 --- a/web/features/agent-v2/roster/components/create-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/create-agent-dialog.tsx @@ -1,5 +1,6 @@ 'use client' import type { AgentAppCreatePayload } from '@dify/contracts/api/console/agent/types.gen' +import type { Ref } from 'react' import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Button } from '@langgenius/dify-ui/button' import { @@ -12,9 +13,8 @@ import { } from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' import { AgentScope } from '@/features/agent-v2/analytics' @@ -30,43 +30,98 @@ type CreateAgentDialogProps = { onOpenChange?: (open: boolean) => void } -export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) { +type CreateAgentFormSessionProps = { + nameInputRef: Ref<HTMLInputElement> + pending: boolean + onCancel: () => void + onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void +} + +const createAgentDefaultValues = { + description: '', + name: '', + role: '', +} satisfies AgentFormValues + +function CreateAgentFormSession({ + nameInputRef, + pending, + onCancel, + onSubmit, +}: CreateAgentFormSessionProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') + const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(defaultAgentIcon) + const [iconPickerOpen, setIconPickerOpen] = useState(false) + + return ( + <> + <div className="shrink-0 ps-6 pe-14 pt-6 pb-3"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['roster.createDialog.title'])} + </DialogTitle> + <DialogDescription className="sr-only"> + {t(($) => $['roster.createDialog.description'])} + </DialogDescription> + </div> + <Form<AgentFormValues> + className="flex min-h-0 flex-1 flex-col" + onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)} + > + <AgentFormFields + ref={nameInputRef} + defaultValues={createAgentDefaultValues} + icon={agentIcon} + iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])} + onIconClick={() => setIconPickerOpen(true)} + /> + <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> + <Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}> + {tCommon(($) => $['operation.cancel'])} + </Button> + <Button type="submit" variant="primary" className="min-w-18" loading={pending}> + {tCommon(($) => $['operation.create'])} + </Button> + </div> + </Form> + <AppIconPicker + open={iconPickerOpen} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } + onOpenChange={setIconPickerOpen} + onSelect={setAgentIcon} + /> + </> + ) +} + +export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) { + const { t } = useTranslation('agentV2') const router = useRouter() const [uncontrolledOpen, setUncontrolledOpen] = useState(false) - const [formKey, setFormKey] = useState(0) - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [role, setRole] = useState('') - const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(defaultAgentIcon) + const nameInputRef = useRef<HTMLInputElement>(null) const createAgentMutation = useMutation(consoleQuery.agent.post.mutationOptions()) - const resetForm = () => { - setFormKey((key) => key + 1) - setName('') - setDescription('') - setRole('') - setAgentIcon(defaultAgentIcon) - setIconPickerOpen(false) + const setDialogOpen = (nextOpen: boolean) => { + if (open === undefined) setUncontrolledOpen(nextOpen) + onOpenChange?.(nextOpen) } const handleOpenChange = (nextOpen: boolean) => { - if (open === undefined) setUncontrolledOpen(nextOpen) - onOpenChange?.(nextOpen) - if (!nextOpen) resetForm() + if (!nextOpen && createAgentMutation.isPending) return + setDialogOpen(nextOpen) } - const handleSubmit = (formValues: AgentFormValues) => { - const trimmedName = formValues.name?.trim() ?? '' - const trimmedRole = formValues.role?.trim() ?? '' + const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => { if (createAgentMutation.isPending) return const body = { - name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, + name: formValues.name.trim(), + description: formValues.description.trim(), + role: formValues.role.trim(), icon_type: agentIcon.type, icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, @@ -83,8 +138,7 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps appMode: 'agent-v2', agentScope: AgentScope.Global, }) - toast.success(t(($) => $['roster.createSuccess'])) - handleOpenChange(false) + setDialogOpen(false) router.push(getAgentDetailPath(createdAgent.id, 'configure')) }, }, @@ -104,73 +158,30 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps <span className="system-sm-medium">{t(($) => $['roster.createAgent'])}</span> </DialogTrigger> )} - <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"> + <DialogContent + initialFocus={nameInputRef} + className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!" + > <DialogClose + disabled={createAgentMutation.isPending} render={ <IconButton aria-label={t(($) => $['operation.close'], { ns: 'common' })} size="lg" - className="absolute inset-e-6 top-6" + className="absolute inset-e-5 top-5" > <span aria-hidden className="i-ri-close-line size-4" /> </IconButton> } /> - <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> - <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t(($) => $['roster.createDialog.title'])} - </DialogTitle> - <DialogDescription className="sr-only"> - {t(($) => $['roster.createDialog.description'])} - </DialogDescription> - </div> - <Form<AgentFormValues> - key={formKey} - className="min-h-0 flex-1" - onFormSubmit={handleSubmit} - > - <AgentFormFields - description={description} - icon={agentIcon} - iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])} - name={name} - role={role} - onDescriptionChange={setDescription} - onIconClick={() => setIconPickerOpen(true)} - onNameChange={setName} - onRoleChange={setRole} - /> - <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button - type="button" - className="min-w-18" - onClick={() => handleOpenChange(false)} - disabled={createAgentMutation.isPending} - > - {tCommon(($) => $['operation.cancel'])} - </Button> - <Button - type="submit" - variant="primary" - className="min-w-18" - loading={createAgentMutation.isPending} - > - {tCommon(($) => $['operation.create'])} - </Button> - </div> - </Form> + <CreateAgentFormSession + nameInputRef={nameInputRef} + pending={createAgentMutation.isPending} + onCancel={() => setDialogOpen(false)} + onSubmit={handleSubmit} + /> </DialogContent> </Dialog> - <AppIconPicker - open={iconPickerOpen} - initialEmoji={ - agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined - } - onOpenChange={setIconPickerOpen} - onSelect={setAgentIcon} - /> </> ) } diff --git a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx index 779adcd1fd1..4f8ba601227 100644 --- a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx @@ -1,9 +1,7 @@ 'use client' -import type { - AgentAppCopyPayload, - AgentAppPartial, -} from '@dify/contracts/api/console/agent/types.gen' -import type { AgentFormValues, AgentIconSelection } from './agent-form' +import type { AgentAppCopyPayload } from '@dify/contracts/api/console/agent/types.gen' +import type { Ref } from 'react' +import type { AgentFormSource, AgentFormValues, AgentIconSelection } from './agent-form' import { Button } from '@langgenius/dify-ui/button' import { Dialog, @@ -12,37 +10,110 @@ import { DialogDescription, DialogTitle, } from '@langgenius/dify-ui/dialog' -import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { Input } from '@langgenius/dify-ui/input' -import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import { consoleQuery } from '@/service/client' import { createAgentIconSelection } from './agent-form' +import { AgentFormFields } from './agent-form-fields' type DuplicateAgentDialogProps = { - agent: AgentAppPartial + agent: AgentFormSource open: boolean onOpenChange: (open: boolean) => void } +type DuplicateAgentFormSessionProps = { + agent: AgentFormSource + nameInputRef: Ref<HTMLInputElement> + pending: boolean + onCancel: () => void + onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void +} + const getDefaultCopyName = (name: string) => { const suffix = ' copy' return `${name.slice(0, 255 - suffix.length)}${suffix}` } -export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) { +function DuplicateAgentFormSession({ + agent, + nameInputRef, + pending, + onCancel, + onSubmit, +}: DuplicateAgentFormSessionProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') + const [initialValues] = useState(() => ({ + fields: { + description: agent.description ?? '', + name: getDefaultCopyName(agent.name), + role: agent.role ?? '', + } satisfies AgentFormValues, + icon: createAgentIconSelection(agent), + sourceName: agent.name, + })) + const [agentIcon, setAgentIcon] = useState(initialValues.icon) + const [iconPickerOpen, setIconPickerOpen] = useState(false) + + return ( + <> + <div className="shrink-0 ps-6 pe-14 pt-6 pb-3"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['roster.duplicateDialog.title'])} + </DialogTitle> + <DialogDescription className="sr-only"> + {t(($) => $['roster.duplicateDialog.description'], { + name: initialValues.sourceName, + })} + </DialogDescription> + </div> + <Form<AgentFormValues> + className="flex min-h-0 flex-1 flex-col" + onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)} + > + <AgentFormFields + ref={nameInputRef} + defaultValues={initialValues.fields} + icon={agentIcon} + iconAriaLabel={t(($) => $['roster.duplicateForm.changeIcon'], { + name: initialValues.sourceName, + })} + onIconClick={() => setIconPickerOpen(true)} + /> + <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> + <Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}> + {tCommon(($) => $['operation.cancel'])} + </Button> + <Button type="submit" variant="primary" className="min-w-18" loading={pending}> + {tCommon(($) => $['operation.duplicate'])} + </Button> + </div> + </Form> + <AppIconPicker + open={iconPickerOpen} + initialEmoji={ + agentIcon.type === 'emoji' + ? { icon: agentIcon.icon, background: agentIcon.background } + : undefined + } + onOpenChange={setIconPickerOpen} + onSelect={setAgentIcon} + /> + </> + ) +} + +export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) { + const { t } = useTranslation('agentV2') const queryClient = useQueryClient() const latestAgent = - queryClient.getQueryData<AgentAppPartial>( + queryClient.getQueryData<AgentFormSource>( consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { @@ -51,30 +122,22 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge }, }), ) ?? agent - const [name, setName] = useState(() => getDefaultCopyName(latestAgent.name)) - const [description, setDescription] = useState(latestAgent.description ?? '') - const [role, setRole] = useState(latestAgent.role ?? '') - const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => - createAgentIconSelection(latestAgent), - ) + const nameInputRef = useRef<HTMLInputElement>(null) const duplicateAgentMutation = useMutation( consoleQuery.agent.byAgentId.copy.post.mutationOptions(), ) const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) setIconPickerOpen(false) + if (!nextOpen && duplicateAgentMutation.isPending) return onOpenChange(nextOpen) } - const handleSubmit = (formValues: AgentFormValues) => { + const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => { if (duplicateAgentMutation.isPending) return - const trimmedName = formValues.name?.trim() ?? '' - const trimmedRole = formValues.role?.trim() ?? '' const body: AgentAppCopyPayload = { - name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, + name: formValues.name.trim(), + description: formValues.description.trim(), + role: formValues.role.trim(), icon_type: agentIcon.type, icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, @@ -90,7 +153,7 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge { onSuccess: () => { toast.success(t(($) => $['roster.duplicateSuccess'])) - handleOpenChange(false) + onOpenChange(false) }, }, ) @@ -99,140 +162,32 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge return ( <> <Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal> - <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"> + <DialogContent + initialFocus={nameInputRef} + className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!" + > <DialogClose + disabled={duplicateAgentMutation.isPending} render={ <IconButton aria-label={t(($) => $['operation.close'], { ns: 'common' })} size="lg" - className="absolute inset-e-6 top-6" + className="absolute inset-e-5 top-5" > <span aria-hidden className="i-ri-close-line size-4" /> </IconButton> } /> - <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> - <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t(($) => $['roster.duplicateDialog.title'])} - </DialogTitle> - <DialogDescription className="sr-only"> - {t(($) => $['roster.duplicateDialog.description'], { name: latestAgent.name })} - </DialogDescription> - </div> - <Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}> - <div className="space-y-5 px-6 py-3"> - <div className="flex items-end gap-4 pb-2"> - <button - type="button" - aria-label={t(($) => $['roster.duplicateForm.changeIcon'], { - name: latestAgent.name, - })} - className="shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - onClick={() => setIconPickerOpen(true)} - > - <AppIcon - size="xxl" - rounded - className="size-16 cursor-pointer" - iconType={agentIcon.type === 'link' ? 'image' : agentIcon.type} - icon={agentIcon.type === 'emoji' ? agentIcon.icon : undefined} - background={agentIcon.type === 'emoji' ? agentIcon.background : undefined} - imageUrl={agentIcon.type === 'emoji' ? undefined : agentIcon.url} - /> - </button> - <div className="flex min-w-0 flex-1 gap-3 pb-1"> - <Field - name="name" - className="relative min-w-0 flex-1" - validate={(value) => { - if (typeof value === 'string' && value.length > 0 && !value.trim()) - return t(($) => $['roster.createForm.nameRequired']) - - return null - }} - > - <FieldLabel>{t(($) => $['roster.createForm.nameLabel'])}</FieldLabel> - <Input - autoComplete="off" - // oxlint-disable-next-line jsx-a11y/no-autofocus -- The duplicate dialog opens from an explicit command, and naming the copy is the primary editable action. - autoFocus - maxLength={255} - onValueChange={setName} - placeholder={t(($) => $['roster.createForm.namePlaceholder'])} - required - value={name} - /> - <div className="absolute top-full left-0 mt-1"> - <FieldError match="valueMissing"> - {t(($) => $['roster.createForm.nameRequired'])} - </FieldError> - <FieldError match="customError" /> - </div> - </Field> - <Field name="role" className="relative min-w-0 flex-1"> - <FieldLabel> - {t(($) => $['roster.createForm.roleLabel'])} - <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon(($) => $['label.optional'])} - </span> - </FieldLabel> - <Input - autoComplete="off" - maxLength={255} - onValueChange={setRole} - placeholder={t(($) => $['roster.createForm.rolePlaceholder'])} - value={role} - /> - </Field> - </div> - </div> - <Field name="description"> - <FieldLabel> - {t(($) => $['roster.createForm.descriptionLabel'])} - <span className="ml-1 system-xs-regular text-text-tertiary"> - {tCommon(($) => $['label.optional'])} - </span> - </FieldLabel> - <Textarea - autoComplete="off" - className="h-20 resize-none" - onValueChange={setDescription} - placeholder={t(($) => $['roster.createForm.descriptionPlaceholder'])} - value={description} - /> - </Field> - </div> - <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button - type="button" - className="min-w-18" - onClick={() => handleOpenChange(false)} - disabled={duplicateAgentMutation.isPending} - > - {tCommon(($) => $['operation.cancel'])} - </Button> - <Button - type="submit" - variant="primary" - className="min-w-18" - loading={duplicateAgentMutation.isPending} - > - {tCommon(($) => $['operation.duplicate'])} - </Button> - </div> - </Form> + <DuplicateAgentFormSession + key={latestAgent.id} + agent={latestAgent} + nameInputRef={nameInputRef} + pending={duplicateAgentMutation.isPending} + onCancel={() => onOpenChange(false)} + onSubmit={handleSubmit} + /> </DialogContent> </Dialog> - <AppIconPicker - open={iconPickerOpen} - initialEmoji={ - agentIcon.type === 'emoji' - ? { icon: agentIcon.icon, background: agentIcon.background } - : undefined - } - onOpenChange={setIconPickerOpen} - onSelect={setAgentIcon} - /> </> ) } diff --git a/web/features/agent-v2/roster/components/edit-agent-dialog.tsx b/web/features/agent-v2/roster/components/edit-agent-dialog.tsx index e1b586d75c7..a35cf69b820 100644 --- a/web/features/agent-v2/roster/components/edit-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/edit-agent-dialog.tsx @@ -1,9 +1,7 @@ 'use client' -import type { - AgentAppPartial, - AgentAppUpdatePayload, -} from '@dify/contracts/api/console/agent/types.gen' -import type { AgentFormValues, AgentIconSelection } from './agent-form' +import type { AgentAppUpdatePayload } from '@dify/contracts/api/console/agent/types.gen' +import type { ChangeEventHandler, Ref } from 'react' +import type { AgentFormSource, AgentFormValues, AgentIconSelection } from './agent-form' import { Button } from '@langgenius/dify-ui/button' import { Dialog, @@ -14,9 +12,8 @@ import { } from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' import { consoleQuery } from '@/service/client' @@ -24,11 +21,19 @@ import { createAgentIconSelection, getAgentIconKey } from './agent-form' import { AgentFormFields } from './agent-form-fields' type EditAgentDialogProps = { - agent: AgentAppPartial + agent: AgentFormSource open: boolean onOpenChange: (open: boolean) => void } +type EditAgentFormSessionProps = { + agent: AgentFormSource + nameInputRef: Ref<HTMLInputElement> + pending: boolean + onCancel: () => void + onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void +} + const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection) => { if (icon.type === 'emoji') { body.icon_type = icon.type @@ -42,133 +47,78 @@ const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection) body.icon_background = undefined } -export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) { +function EditAgentFormSession({ + agent, + nameInputRef, + pending, + onCancel, + onSubmit, +}: EditAgentFormSessionProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') - const [name, setName] = useState(agent.name) - const [description, setDescription] = useState(agent.description ?? '') - const [role, setRole] = useState(agent.role ?? '') + const [initialValues] = useState(() => ({ + fields: { + description: agent.description ?? '', + name: agent.name, + role: agent.role ?? '', + } satisfies AgentFormValues, + icon: createAgentIconSelection(agent), + })) + const [agentIcon, setAgentIcon] = useState(initialValues.icon) const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => - createAgentIconSelection(agent), - ) - const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions()) + const [hasTextChanges, setHasTextChanges] = useState(false) + const hasIconChanges = getAgentIconKey(agentIcon) !== getAgentIconKey(initialValues.icon) + const hasChanges = hasTextChanges || hasIconChanges - const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) setIconPickerOpen(false) - onOpenChange(nextOpen) - } - - const handleSubmit = (formValues: AgentFormValues) => { - const trimmedName = formValues.name?.trim() ?? '' - const trimmedDescription = formValues.description?.trim() ?? '' - const trimmedRole = formValues.role?.trim() ?? '' - const hasIconChanges = - getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent)) - const hasFormChanges = - trimmedName !== agent.name.trim() || - trimmedDescription !== (agent.description?.trim() ?? '') || - trimmedRole !== (agent.role?.trim() ?? '') || - hasIconChanges - - if (updateAgentMutation.isPending) return - - if (!hasFormChanges) return - - const body: AgentAppUpdatePayload = { - name: trimmedName, - description: trimmedDescription, - // Keep sending the trimmed role even when empty: omitting the field - // preserves the current backing-agent role, while "" intentionally clears it. - role: trimmedRole, - } - - applyIconPayload(body, agentIcon) - - updateAgentMutation.mutate( - { - params: { - agent_id: agent.id, - }, - body, - }, - { - onSuccess: () => { - toast.success(t(($) => $['roster.updateSuccess'])) - handleOpenChange(false) - }, - }, + const handleFormChange: ChangeEventHandler<HTMLFormElement> = (event) => { + const formValues = new FormData(event.currentTarget) + setHasTextChanges( + String(formValues.get('name') ?? '').trim() !== initialValues.fields.name.trim() || + String(formValues.get('description') ?? '').trim() !== + initialValues.fields.description.trim() || + String(formValues.get('role') ?? '').trim() !== initialValues.fields.role.trim(), ) } - const trimmedName = name.trim() - const trimmedDescription = description.trim() - const trimmedRole = role.trim() - const hasIconChanges = - getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent)) - const hasChanges = - trimmedName !== agent.name.trim() || - trimmedDescription !== (agent.description?.trim() ?? '') || - trimmedRole !== (agent.role?.trim() ?? '') || - hasIconChanges - return ( <> - <Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal> - <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"> - <DialogClose - render={ - <IconButton - aria-label={t(($) => $['operation.close'], { ns: 'common' })} - size="lg" - className="absolute inset-e-6 top-6" - > - <span aria-hidden className="i-ri-close-line size-4" /> - </IconButton> - } - /> - <div className="shrink-0 pt-6 pr-14 pb-3 pl-6"> - <DialogTitle className="title-2xl-semi-bold text-text-primary"> - {t(($) => $['roster.editDialog.title'])} - </DialogTitle> - <DialogDescription className="sr-only"> - {t(($) => $['roster.editDialog.description'])} - </DialogDescription> - </div> - <Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}> - <AgentFormFields - description={description} - icon={agentIcon} - iconAriaLabel={t(($) => $['roster.editAgent'], { name: agent.name })} - name={name} - role={role} - onDescriptionChange={setDescription} - onIconClick={() => setIconPickerOpen(true)} - onNameChange={setName} - onRoleChange={setRole} - /> - <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> - <Button - type="button" - className="min-w-18" - onClick={() => handleOpenChange(false)} - disabled={updateAgentMutation.isPending} - > - {tCommon(($) => $['operation.cancel'])} - </Button> - <Button - type="submit" - variant="primary" - className="min-w-18" - disabled={!hasChanges} - loading={updateAgentMutation.isPending} - > - {tCommon(($) => $['operation.save'])} - </Button> - </div> - </Form> - </DialogContent> - </Dialog> + <div className="shrink-0 ps-6 pe-14 pt-6 pb-3"> + <DialogTitle className="title-2xl-semi-bold text-text-primary"> + {t(($) => $['roster.editDialog.title'])} + </DialogTitle> + <DialogDescription className="sr-only"> + {t(($) => $['roster.editDialog.description'])} + </DialogDescription> + </div> + <Form<AgentFormValues> + className="flex min-h-0 flex-1 flex-col" + onChange={handleFormChange} + onFormSubmit={(formValues) => { + if (hasChanges) onSubmit(formValues, agentIcon) + }} + > + <AgentFormFields + ref={nameInputRef} + defaultValues={initialValues.fields} + icon={agentIcon} + iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])} + onIconClick={() => setIconPickerOpen(true)} + /> + <div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6"> + <Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}> + {tCommon(($) => $['operation.cancel'])} + </Button> + <Button + type="submit" + variant="primary" + className="min-w-18" + disabled={!hasChanges} + loading={pending} + > + {tCommon(($) => $['operation.save'])} + </Button> + </div> + </Form> <AppIconPicker open={iconPickerOpen} initialEmoji={ @@ -182,3 +132,74 @@ export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogPr </> ) } + +export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) { + const { t } = useTranslation('agentV2') + const nameInputRef = useRef<HTMLInputElement>(null) + const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions()) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && updateAgentMutation.isPending) return + onOpenChange(nextOpen) + } + + const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => { + if (updateAgentMutation.isPending) return + + const body: AgentAppUpdatePayload = { + name: formValues.name.trim(), + description: formValues.description.trim(), + // Keep sending the trimmed role even when empty: omitting the field + // preserves the current backing-agent role, while "" intentionally clears it. + role: formValues.role.trim(), + } + + applyIconPayload(body, agentIcon) + + updateAgentMutation.mutate( + { + params: { + agent_id: agent.id, + }, + body, + }, + { + onSuccess: () => { + onOpenChange(false) + }, + }, + ) + } + + return ( + <> + <Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal> + <DialogContent + initialFocus={nameInputRef} + className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!" + > + <DialogClose + disabled={updateAgentMutation.isPending} + render={ + <IconButton + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + size="lg" + className="absolute inset-e-5 top-5" + > + <span aria-hidden className="i-ri-close-line size-4" /> + </IconButton> + } + /> + <EditAgentFormSession + key={agent.id} + agent={agent} + nameInputRef={nameInputRef} + pending={updateAgentMutation.isPending} + onCancel={() => onOpenChange(false)} + onSubmit={handleSubmit} + /> + </DialogContent> + </Dialog> + </> + ) +} diff --git a/web/features/home/continue-work/__tests__/item.spec.tsx b/web/features/home/continue-work/__tests__/item.spec.tsx index ee61d10d6ce..37b74a4b944 100644 --- a/web/features/home/continue-work/__tests__/item.spec.tsx +++ b/web/features/home/continue-work/__tests__/item.spec.tsx @@ -168,10 +168,15 @@ describe('ContinueWorkItem', () => { ) }) - it('should fall back to access point when RBAC is disabled for an access-config-only app', () => { - renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { - rbac_enabled: false, - }) + it('should fall back to access point when RBAC is disabled for an access-config app with access point permission', () => { + renderItem( + createApp({ + permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPoint], + }), + { + rbac_enabled: false, + }, + ) expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute( 'href', diff --git a/web/global.d.ts b/web/global.d.ts index 5a68838920c..5d1e51bce0a 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -19,5 +19,18 @@ declare global { interface Window { gtag?: Gtag dataLayer?: unknown[] + __marketplaceTracking__?: { + track: (eventName: string, properties?: Record<string, unknown>) => void + rememberReferrer: (itemId: string, section: 'banner' | 'search' | 'list' | 'direct') => void + markSearch: (query: string) => void + flushSearch: (resultCount: number) => void + markFilter: (filter: { + filter_type: 'type_tab' | 'category' | 'language' + selection_mode: 'single' | 'multi' + filter_value: string + selected_values: string[] + }) => void + flushFilter: (resultCount: number) => void + } } } diff --git a/web/hooks/use-import-dsl.spec.tsx b/web/hooks/use-import-dsl.spec.tsx index 1f6e95205f9..d8aff462318 100644 --- a/web/hooks/use-import-dsl.spec.tsx +++ b/web/hooks/use-import-dsl.spec.tsx @@ -1,4 +1,4 @@ -import { act, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import { DSLImportMode, DSLImportStatus } from '@/models/app' import { renderHookWithConsoleQuery } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' @@ -93,6 +93,46 @@ describe('useImportDSL', () => { mockResolveImportedAppRedirectionTarget.mockImplementation(async (target) => target) }) + it('should show response warnings when an import completes with warnings', async () => { + const completedResponse = { + id: 'import-1', + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + app_id: 'app-1', + app_mode: AppModeEnum.WORKFLOW, + permission_keys: [], + warnings: [ + { + code: 'agent_tool_authorization_required', + path: 'agent_packages.agent_1.soul.tools.dify_tools.0', + message: "Agent tool 'jina_search' requires authorization.", + details: { tool_name: 'jina_search' }, + }, + ], + } + mockImportDSL.mockResolvedValue(completedResponse) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHookWithConsoleQuery(() => useImportDSL()) + + await act(async () => { + await result.current.handleImportDSL( + { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'app: demo', + }, + { skipRedirectOnSuccess: true }, + ) + }) + + expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', { + description: expect.anything(), + }) + const warningDescription = toastMocks.warning.mock.calls[0]![1].description + render(<>{warningDescription}</>) + expect(screen.getByText("Agent tool 'jina_search' requires authorization.")).toBeInTheDocument() + expect(screen.queryByText('app.newApp.appCreateDSLWarning')).not.toBeInTheDocument() + }) + it('should complete a confirmed import that returns warnings', async () => { let resolvePluginCheck: (() => void) | undefined const pendingResponse = { @@ -163,8 +203,12 @@ describe('useImportDSL', () => { expect(onSuccess).toHaveBeenCalledWith(completedResponse) expect(onFailed).not.toHaveBeenCalled() expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', { - description: 'app.newApp.appCreateDSLWarning', + description: expect.anything(), }) + const warningDescription = toastMocks.warning.mock.calls[0]![1].description + render(<>{warningDescription}</>) + expect(screen.getByText('Agent file was not included.')).toBeInTheDocument() + expect(screen.queryByText('app.newApp.appCreateDSLWarning')).not.toBeInTheDocument() expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1') expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({ id: 'app-1', diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index dda857efdb5..81e32d46a91 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -3,8 +3,9 @@ import type { AppIconType } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { useCallback, useRef, useState } from 'react' +import { createElement, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import DSLImportWarningDescription from '@/app/components/app/create-from-dsl-modal/dsl-import-warning-description' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' @@ -80,7 +81,10 @@ export const useImportDSL = () => { ) const description = status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + ? createElement(DSLImportWarningDescription, { + warnings: response.warnings, + fallback: t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }), + }) : undefined if (status === DSLImportStatus.COMPLETED) toast.success(message) @@ -162,7 +166,10 @@ export const useImportDSL = () => { ) const description = status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + ? createElement(DSLImportWarningDescription, { + warnings: response.warnings, + fallback: t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }), + }) : undefined if (status === DSLImportStatus.COMPLETED) toast.success(message) diff --git a/web/i18n-config/__tests__/plural-selector.spec.ts b/web/i18n-config/__tests__/plural-selector.spec.ts index 9e98128f67e..ceeed3aeaa4 100644 --- a/web/i18n-config/__tests__/plural-selector.spec.ts +++ b/web/i18n-config/__tests__/plural-selector.spec.ts @@ -1,6 +1,6 @@ import type { SelectorParam } from 'i18next' import { createInstance } from 'i18next' -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import agentV2 from '../../i18n/en-US/agent-v-2.json' import skill from '../../i18n/en-US/skill.json' import { getInitOptions } from '../settings' diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index a60ccd28834..7e0d59840d0 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "تنتهي في {{count}} أيام", "license.unlimited": "غير محدود", "loading": "جارٍ التحميل", + "mainNav.help.creatorCenter": "مركز المبدعين", "mainNav.help.docs": "الوثائق", "mainNav.help.learnDify": "تعلّم Dify", "mainNav.help.openMenu": "فتح قائمة المساعدة", @@ -669,6 +670,7 @@ "userProfile.about": "حول", "userProfile.compliance": "الامتثال", "userProfile.contactUs": "اتصل بنا", + "userProfile.discord": "Discord", "userProfile.emailSupport": "دعم البريد الإلكتروني", "userProfile.github": "GitHub", "userProfile.helpCenter": "عرض المستندات", diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json index a7f129455c5..18321e3b376 100644 --- a/web/i18n/ar-TN/permission-keys.json +++ b/web/i18n/ar-TN/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "إدارة إعدادات امتداد API", "app.access_config": "تكوين أذونات الوصول إلى التطبيق", "app.acl.access_config": "عرض أذونات الوصول وإدارتها", + "app.acl.access_point_manage": "عرض نقاط الوصول وإدارتها", "app.acl.delete": "حذف التطبيق", "app.acl.deploy": "نشر التطبيق", "app.acl.edit": "تعديل معلومات التطبيق وتنسيقه", diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json index 4d6627fbcbf..a7c37b5f10d 100644 --- a/web/i18n/ar-TN/plugin.json +++ b/web/i18n/ar-TN/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "جميع الإضافات", "marketplace.and": "و", "marketplace.becomePartner": "كن شريكًا", + "marketplace.carousel.goToPage": "الانتقال إلى الصفحة {{page}}", + "marketplace.carousel.scrollNext": "الصفحة التالية", + "marketplace.carousel.scrollPrevious": "الصفحة السابقة", + "marketplace.creatorProfile.breadcrumbLabel": "مسار التنقل", + "marketplace.creatorProfile.creations": "الأعمال", + "marketplace.creatorProfile.empty": "لا توجد أعمال بعد.", + "marketplace.creatorProfile.home": "الصفحة الرئيسية للسوق", + "marketplace.creatorProfile.onTheWeb": "على الويب", + "marketplace.creatorProfile.organization": "منظمة", + "marketplace.creatorProfile.searchPlaceholder": "ابحث عن الإضافات والقوالب", + "marketplace.creatorProfile.sort.asc": "ترتيب تصاعدي", + "marketplace.creatorProfile.sort.createdAt": "الأحدث إنشاءً", + "marketplace.creatorProfile.sort.desc": "ترتيب تنازلي", + "marketplace.creatorProfile.sort.popularity": "الشعبية", + "marketplace.creatorProfile.sort.updatedAt": "الأحدث تحديثًا", + "marketplace.creatorProfile.sortBy": "ترتيب حسب", + "marketplace.creatorProfile.title": "ملف المنشئ", + "marketplace.creatorProfile.type.plugin": "إضافة", + "marketplace.creatorProfile.type.template": "قالب", "marketplace.difyMarketplace": "سوق Dify", "marketplace.discover": "اكتشف", "marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك", + "marketplace.home.creatorCenter": "مركز المبدعين", + "marketplace.home.guide": "دليل", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "اكتشف. وسّع. ابنِ", + "marketplace.home.plugins": "المكونات الإضافية", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "قوالب", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "إيقاف مؤقت", + "marketplace.home.trendingPlay": "تشغيل", + "marketplace.home.trendingReadMore": "اقرأ المزيد", + "marketplace.home.trendingReadMoreAbout": "اقرأ المزيد عن {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "عرض", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "فشل التحميل. يرجى المحاولة مرة أخرى.", "marketplace.moreFrom": "المزيد من السوق", "marketplace.noPluginFound": "لم يتم العثور على إضافة", "marketplace.partnerTip": "تم التحقق بواسطة شريك Dify", "marketplace.pluginsHeroSubtitle": "استخدم الإضافات التي بناها المجتمع لتعزيز تطوير الذكاء الاصطناعي الخاص بك.", "marketplace.pluginsHeroTitle": "اكتشف. وسّع. ابنِ.", "marketplace.pluginsResult": "{{num}} نتائج", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "فرز حسب", "marketplace.sortOption.firstReleased": "صدر لأول مرة", "marketplace.sortOption.mostPopular": "الأكثر شيوعًا", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 52978625901..39d56391d07 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Läuft in {{count}} Tagen ab", "license.unlimited": "Unbegrenzt", "loading": "Wird geladen", + "mainNav.help.creatorCenter": "Creator Center", "mainNav.help.docs": "Dokumentation", "mainNav.help.learnDify": "Dify kennenlernen", "mainNav.help.openMenu": "Hilfemenü öffnen", @@ -669,6 +670,7 @@ "userProfile.about": "Über", "userProfile.compliance": "Einhaltung", "userProfile.contactUs": "Kontaktieren Sie uns", + "userProfile.discord": "Discord", "userProfile.emailSupport": "E-Mail-Support", "userProfile.github": "GitHub", "userProfile.helpCenter": "Hilfe", diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json index fb32d92c699..2d546c0e082 100644 --- a/web/i18n/de-DE/permission-keys.json +++ b/web/i18n/de-DE/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "API-Erweiterungskonfiguration verwalten", "app.access_config": "App-Zugriffsberechtigungen konfigurieren", "app.acl.access_config": "Zugriffsberechtigungen anzeigen und verwalten", + "app.acl.access_point_manage": "Zugangspunkte anzeigen und verwalten", "app.acl.delete": "App löschen", "app.acl.deploy": "App bereitstellen", "app.acl.edit": "App-Informationen bearbeiten und App orchestrieren", diff --git a/web/i18n/de-DE/plugin.json b/web/i18n/de-DE/plugin.json index e58db219428..4c3d321beff 100644 --- a/web/i18n/de-DE/plugin.json +++ b/web/i18n/de-DE/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Alle Plugins", "marketplace.and": "und", "marketplace.becomePartner": "Partner werden", + "marketplace.carousel.goToPage": "Zu Seite {{page}} wechseln", + "marketplace.carousel.scrollNext": "Nächste Seite", + "marketplace.carousel.scrollPrevious": "Vorherige Seite", + "marketplace.creatorProfile.breadcrumbLabel": "Brotkrümelnavigation", + "marketplace.creatorProfile.creations": "Kreationen", + "marketplace.creatorProfile.empty": "Noch keine Kreationen.", + "marketplace.creatorProfile.home": "Marketplace-Startseite", + "marketplace.creatorProfile.onTheWeb": "Im Web", + "marketplace.creatorProfile.organization": "Organisation", + "marketplace.creatorProfile.searchPlaceholder": "Plugins und Vorlagen suchen", + "marketplace.creatorProfile.sort.asc": "Aufsteigend sortieren", + "marketplace.creatorProfile.sort.createdAt": "Zuletzt erstellt", + "marketplace.creatorProfile.sort.desc": "Absteigend sortieren", + "marketplace.creatorProfile.sort.popularity": "Beliebtheit", + "marketplace.creatorProfile.sort.updatedAt": "Zuletzt aktualisiert", + "marketplace.creatorProfile.sortBy": "Sortieren nach", + "marketplace.creatorProfile.title": "Creator-Profil", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Vorlage", "marketplace.difyMarketplace": "Dify Marktplatz", "marketplace.discover": "Entdecken", "marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung", + "marketplace.home.creatorCenter": "Creator Center", + "marketplace.home.guide": "Leitfaden", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Entdecken. Erweitern. Entwickeln", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Vorlagen", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pausieren", + "marketplace.home.trendingPlay": "Abspielen", + "marketplace.home.trendingReadMore": "Mehr erfahren", + "marketplace.home.trendingReadMoreAbout": "Mehr erfahren über {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Ansehen", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Laden fehlgeschlagen. Bitte versuchen Sie es erneut.", "marketplace.moreFrom": "Mehr aus dem Marketplace", "marketplace.noPluginFound": "Kein Plugin gefunden", "marketplace.partnerTip": "Von einem Dify-Partner verifiziert", "marketplace.pluginsHeroSubtitle": "Nutzen Sie von der Community erstellte Plugins, um Ihre KI-Entwicklung voranzutreiben.", "marketplace.pluginsHeroTitle": "Entdecken. Erweitern. Entwickeln.", "marketplace.pluginsResult": "{{num}} Ergebnisse", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sortieren nach", "marketplace.sortOption.firstReleased": "Zuerst veröffentlicht", "marketplace.sortOption.mostPopular": "Beliebteste", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 0fc4518c58f..dad47fe359e 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expiring in {{count}} days", "license.unlimited": "Unlimited", "loading": "Loading", + "mainNav.help.creatorCenter": "Creator Center", "mainNav.help.docs": "Documentation", "mainNav.help.learnDify": "Learn Dify", "mainNav.help.openMenu": "Open help menu", @@ -669,6 +670,7 @@ "userProfile.about": "About", "userProfile.compliance": "Compliance", "userProfile.contactUs": "Contact Us", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Email Support", "userProfile.github": "GitHub", "userProfile.helpCenter": "View Docs", diff --git a/web/i18n/en-US/permission-keys.json b/web/i18n/en-US/permission-keys.json index 2344caa11e1..e69f68bb0b3 100644 --- a/web/i18n/en-US/permission-keys.json +++ b/web/i18n/en-US/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Manage API extension configuration", "app.access_config": "Configure app access permissions", "app.acl.access_config": "View and manage access permissions", + "app.acl.access_point_manage": "View and manage access points", "app.acl.delete": "Delete app", "app.acl.deploy": "Deploy app", "app.acl.edit": "Edit app information and orchestrate app", diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index ab7f1cc304a..18abf4dd904 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "All integrations", "marketplace.and": "and", "marketplace.becomePartner": "Become a Partner", + "marketplace.carousel.goToPage": "Go to page {{page}}", + "marketplace.carousel.scrollNext": "Next page", + "marketplace.carousel.scrollPrevious": "Previous page", + "marketplace.creatorProfile.breadcrumbLabel": "Breadcrumb", + "marketplace.creatorProfile.creations": "Creations", + "marketplace.creatorProfile.empty": "No creations yet.", + "marketplace.creatorProfile.home": "Marketplace home", + "marketplace.creatorProfile.onTheWeb": "On the web", + "marketplace.creatorProfile.organization": "Organization", + "marketplace.creatorProfile.searchPlaceholder": "Search plugins and templates", + "marketplace.creatorProfile.sort.asc": "Sort ascending", + "marketplace.creatorProfile.sort.createdAt": "Recently created", + "marketplace.creatorProfile.sort.desc": "Sort descending", + "marketplace.creatorProfile.sort.popularity": "Popularity", + "marketplace.creatorProfile.sort.updatedAt": "Recently updated", + "marketplace.creatorProfile.sortBy": "Sort by", + "marketplace.creatorProfile.title": "Creator Profile", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Template", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Discover", "marketplace.empower": "Empower your AI development", + "marketplace.home.creatorCenter": "Creator Center", + "marketplace.home.guide": "Guide", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Discover. Extend. Build", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Templates", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pause", + "marketplace.home.trendingPlay": "Play", + "marketplace.home.trendingReadMore": "Read more", + "marketplace.home.trendingReadMoreAbout": "Read more about {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "View", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Failed to load. Please try again.", "marketplace.moreFrom": "More from Marketplace", "marketplace.noPluginFound": "No integration found", "marketplace.partnerTip": "Verified by a Dify partner", "marketplace.pluginsHeroSubtitle": "Use community-built integrations to power your AI development.", "marketplace.pluginsHeroTitle": "Discover. Extend. Build.", "marketplace.pluginsResult": "{{num}} results", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sort by", "marketplace.sortOption.firstReleased": "First Released", "marketplace.sortOption.mostPopular": "Most Popular", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 5dc1d0bacfe..b5b2c5c8617 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Caducando en {{count}} días", "license.unlimited": "Ilimitado", "loading": "Cargando", + "mainNav.help.creatorCenter": "Centro de creadores", "mainNav.help.docs": "Documentación", "mainNav.help.learnDify": "Aprende Dify", "mainNav.help.openMenu": "Abrir menú de ayuda", @@ -669,6 +670,7 @@ "userProfile.about": "Acerca de", "userProfile.compliance": "Cumplimiento", "userProfile.contactUs": "Contáctenos", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Soporte de Correo Electrónico", "userProfile.github": "GitHub", "userProfile.helpCenter": "Ayuda", diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json index af3336ceea7..d7d5e9d57c8 100644 --- a/web/i18n/es-ES/permission-keys.json +++ b/web/i18n/es-ES/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Gestionar la configuración de la extensión de API", "app.access_config": "Configurar los permisos de acceso de la app", "app.acl.access_config": "Ver y gestionar los permisos de acceso", + "app.acl.access_point_manage": "Ver y gestionar los puntos de acceso", "app.acl.delete": "Eliminar app", "app.acl.deploy": "Desplegar la app", "app.acl.edit": "Editar la información y orquestar la app", diff --git a/web/i18n/es-ES/plugin.json b/web/i18n/es-ES/plugin.json index 7fe09f475fb..e57b989c6a1 100644 --- a/web/i18n/es-ES/plugin.json +++ b/web/i18n/es-ES/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Todas las integraciones", "marketplace.and": "y", "marketplace.becomePartner": "Conviértete en socio", + "marketplace.carousel.goToPage": "Ir a la página {{page}}", + "marketplace.carousel.scrollNext": "Página siguiente", + "marketplace.carousel.scrollPrevious": "Página anterior", + "marketplace.creatorProfile.breadcrumbLabel": "Ruta de navegación", + "marketplace.creatorProfile.creations": "Creaciones", + "marketplace.creatorProfile.empty": "Aún no hay creaciones.", + "marketplace.creatorProfile.home": "Inicio del Marketplace", + "marketplace.creatorProfile.onTheWeb": "En la web", + "marketplace.creatorProfile.organization": "Organización", + "marketplace.creatorProfile.searchPlaceholder": "Buscar plugins y plantillas", + "marketplace.creatorProfile.sort.asc": "Orden ascendente", + "marketplace.creatorProfile.sort.createdAt": "Recién creado", + "marketplace.creatorProfile.sort.desc": "Orden descendente", + "marketplace.creatorProfile.sort.popularity": "Popularidad", + "marketplace.creatorProfile.sort.updatedAt": "Recién actualizado", + "marketplace.creatorProfile.sortBy": "Ordenar por", + "marketplace.creatorProfile.title": "Perfil del creador", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Plantilla", "marketplace.difyMarketplace": "Mercado de Dify", "marketplace.discover": "Descubrir", "marketplace.empower": "Potencie su desarrollo de IA", + "marketplace.home.creatorCenter": "Centro de creadores", + "marketplace.home.guide": "Guía", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Descubre. Amplía. Crea", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Plantillas", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pausar", + "marketplace.home.trendingPlay": "Reproducir", + "marketplace.home.trendingReadMore": "Leer más", + "marketplace.home.trendingReadMoreAbout": "Leer más sobre {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Ver", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Error al cargar. Inténtalo de nuevo.", "marketplace.moreFrom": "Más de Marketplace", "marketplace.noPluginFound": "No se ha encontrado ninguna integración", "marketplace.partnerTip": "Verificado por un socio de Dify", "marketplace.pluginsHeroSubtitle": "Usa integraciones creadas por la comunidad para potenciar tu desarrollo de IA.", "marketplace.pluginsHeroTitle": "Descubre. Amplía. Crea.", "marketplace.pluginsResult": "{{num}} resultados", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Ordenar por", "marketplace.sortOption.firstReleased": "Lanzado por primera vez", "marketplace.sortOption.mostPopular": "Lo más popular", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 4ed2f54264d..35a5f7e8c30 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "انقضا در {{count}} روز", "license.unlimited": "نامحدود", "loading": "در حال بارگذاری", + "mainNav.help.creatorCenter": "مرکز سازندگان", "mainNav.help.docs": "مستندات", "mainNav.help.learnDify": "یادگیری Dify", "mainNav.help.openMenu": "باز کردن منوی راهنما", @@ -669,6 +670,7 @@ "userProfile.about": "درباره", "userProfile.compliance": "انطباق", "userProfile.contactUs": "با ما تماس بگیرید", + "userProfile.discord": "Discord", "userProfile.emailSupport": "پشتیبانی ایمیل", "userProfile.github": "گیت‌هاب", "userProfile.helpCenter": "راهنما", diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json index 372374ed3b8..5e739bed336 100644 --- a/web/i18n/fa-IR/permission-keys.json +++ b/web/i18n/fa-IR/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "مدیریت پیکربندی افزونه API", "app.access_config": "پیکربندی مجوزهای دسترسی برنامه", "app.acl.access_config": "مشاهده و مدیریت مجوزهای دسترسی", + "app.acl.access_point_manage": "مشاهده و مدیریت نقاط دسترسی", "app.acl.delete": "حذف برنامه", "app.acl.deploy": "استقرار برنامه", "app.acl.edit": "ویرایش اطلاعات برنامه و هماهنگ‌سازی برنامه", diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json index bb374e97333..d1adac64969 100644 --- a/web/i18n/fa-IR/plugin.json +++ b/web/i18n/fa-IR/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "همه افزونه‌ها", "marketplace.and": "و", "marketplace.becomePartner": "شریک شوید", + "marketplace.carousel.goToPage": "رفتن به صفحه {{page}}", + "marketplace.carousel.scrollNext": "صفحه بعدی", + "marketplace.carousel.scrollPrevious": "صفحه قبلی", + "marketplace.creatorProfile.breadcrumbLabel": "مسیر راهنما", + "marketplace.creatorProfile.creations": "آثار", + "marketplace.creatorProfile.empty": "هنوز اثری وجود ندارد.", + "marketplace.creatorProfile.home": "خانه Marketplace", + "marketplace.creatorProfile.onTheWeb": "در وب", + "marketplace.creatorProfile.organization": "سازمان", + "marketplace.creatorProfile.searchPlaceholder": "جستجوی افزونه و قالب", + "marketplace.creatorProfile.sort.asc": "مرتب‌سازی صعودی", + "marketplace.creatorProfile.sort.createdAt": "تازه‌ساخته", + "marketplace.creatorProfile.sort.desc": "مرتب‌سازی نزولی", + "marketplace.creatorProfile.sort.popularity": "محبوبیت", + "marketplace.creatorProfile.sort.updatedAt": "تازه‌به‌روزرسانی", + "marketplace.creatorProfile.sortBy": "مرتب‌سازی بر اساس", + "marketplace.creatorProfile.title": "نمایه سازنده", + "marketplace.creatorProfile.type.plugin": "افزونه", + "marketplace.creatorProfile.type.template": "قالب", "marketplace.difyMarketplace": "بازار دیفی", "marketplace.discover": "کشف", "marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید", + "marketplace.home.creatorCenter": "مرکز سازندگان", + "marketplace.home.guide": "راهنما", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "کشف کنید. گسترش دهید. بسازید", + "marketplace.home.plugins": "افزونه‌ها", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "الگوها", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "توقف", + "marketplace.home.trendingPlay": "پخش", + "marketplace.home.trendingReadMore": "ادامه مطلب", + "marketplace.home.trendingReadMoreAbout": "ادامه مطلب درباره {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "مشاهده", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "بارگیری ناموفق بود. لطفاً دوباره تلاش کنید.", "marketplace.moreFrom": "اطلاعات بیشتر از Marketplace", "marketplace.noPluginFound": "هیچ افزونه‌ای یافت نشد", "marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی", "marketplace.pluginsHeroSubtitle": "از افزونه‌های ساخته‌شده توسط جامعه برای تقویت توسعه هوش مصنوعی خود استفاده کنید.", "marketplace.pluginsHeroTitle": "کشف کنید. گسترش دهید. بسازید.", "marketplace.pluginsResult": "نتایج {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "شهر سیاه", "marketplace.sortOption.firstReleased": "اولین منتشر شد", "marketplace.sortOption.mostPopular": "محبوب ترین", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index b0f8d11a4e3..c3f8593b602 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expirant dans {{count}} jours", "license.unlimited": "Illimité", "loading": "Chargement", + "mainNav.help.creatorCenter": "Centre des créateurs", "mainNav.help.docs": "Documentation", "mainNav.help.learnDify": "Apprendre Dify", "mainNav.help.openMenu": "Ouvrir le menu d’aide", @@ -669,6 +670,7 @@ "userProfile.about": "À propos", "userProfile.compliance": "Conformité", "userProfile.contactUs": "Contactez-nous", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Support par courriel", "userProfile.github": "GitHub", "userProfile.helpCenter": "Aide", diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json index 074da133fae..c547052586d 100644 --- a/web/i18n/fr-FR/permission-keys.json +++ b/web/i18n/fr-FR/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Gérer la configuration de l'extension API", "app.access_config": "Configurer les autorisations d'accès à l'application", "app.acl.access_config": "Afficher et gérer les autorisations d'accès", + "app.acl.access_point_manage": "Afficher et gérer les points d’accès", "app.acl.delete": "Supprimer l'application", "app.acl.deploy": "Déployer l'application", "app.acl.edit": "Modifier les informations et orchestrer l'application", diff --git a/web/i18n/fr-FR/plugin.json b/web/i18n/fr-FR/plugin.json index 7ce28a6030e..8cc5485fdff 100644 --- a/web/i18n/fr-FR/plugin.json +++ b/web/i18n/fr-FR/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Toutes les intégrations", "marketplace.and": "et", "marketplace.becomePartner": "Devenir partenaire", + "marketplace.carousel.goToPage": "Aller à la page {{page}}", + "marketplace.carousel.scrollNext": "Page suivante", + "marketplace.carousel.scrollPrevious": "Page précédente", + "marketplace.creatorProfile.breadcrumbLabel": "Fil d'Ariane", + "marketplace.creatorProfile.creations": "Créations", + "marketplace.creatorProfile.empty": "Aucune création pour le moment.", + "marketplace.creatorProfile.home": "Accueil du Marketplace", + "marketplace.creatorProfile.onTheWeb": "Sur le web", + "marketplace.creatorProfile.organization": "Organisation", + "marketplace.creatorProfile.searchPlaceholder": "Rechercher des plugins et des modèles", + "marketplace.creatorProfile.sort.asc": "Trier par ordre croissant", + "marketplace.creatorProfile.sort.createdAt": "Récemment créé", + "marketplace.creatorProfile.sort.desc": "Trier par ordre décroissant", + "marketplace.creatorProfile.sort.popularity": "Popularité", + "marketplace.creatorProfile.sort.updatedAt": "Récemment mis à jour", + "marketplace.creatorProfile.sortBy": "Trier par", + "marketplace.creatorProfile.title": "Profil du créateur", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Modèle", "marketplace.difyMarketplace": "Marché Dify", "marketplace.discover": "Découvrir", "marketplace.empower": "Renforcez le développement de votre IA", + "marketplace.home.creatorCenter": "Centre des créateurs", + "marketplace.home.guide": "Guide", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Découvrez. Étendez. Créez", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Modèles", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Mettre en pause", + "marketplace.home.trendingPlay": "Lire", + "marketplace.home.trendingReadMore": "En savoir plus", + "marketplace.home.trendingReadMoreAbout": "En savoir plus sur {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Voir", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Échec du chargement. Veuillez réessayer.", "marketplace.moreFrom": "Plus de Marketplace", "marketplace.noPluginFound": "Aucune intégration trouvée", "marketplace.partnerTip": "Vérifié par un partenaire Dify", "marketplace.pluginsHeroSubtitle": "Utilisez des intégrations créées par la communauté pour propulser votre développement de l’IA.", "marketplace.pluginsHeroTitle": "Découvrir. Étendre. Construire.", "marketplace.pluginsResult": "{{num}} résultats", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Ville noire", "marketplace.sortOption.firstReleased": "Première sortie", "marketplace.sortOption.mostPopular": "Les plus populaires", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index cc501813156..d06b91847f9 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "{{count}} दिनों में समाप्त हो रहा है", "license.unlimited": "असीमित", "loading": "लोड हो रहा है", + "mainNav.help.creatorCenter": "क्रिएटर केंद्र", "mainNav.help.docs": "दस्तावेज़", "mainNav.help.learnDify": "Dify सीखें", "mainNav.help.openMenu": "सहायता मेनू खोलें", @@ -669,6 +670,7 @@ "userProfile.about": "के बारे में", "userProfile.compliance": "अनुपालन", "userProfile.contactUs": "संपर्क करें", + "userProfile.discord": "Discord", "userProfile.emailSupport": "सहायता", "userProfile.github": "गिटहब", "userProfile.helpCenter": "सहायता", diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json index 314cbfba84b..0779d870342 100644 --- a/web/i18n/hi-IN/permission-keys.json +++ b/web/i18n/hi-IN/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें", "app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", "app.acl.access_config": "एक्सेस अनुमतियाँ देखें और प्रबंधित करें", + "app.acl.access_point_manage": "एक्सेस पॉइंट देखें और प्रबंधित करें", "app.acl.delete": "ऐप हटाएं", "app.acl.deploy": "ऐप डिप्लॉय करें", "app.acl.edit": "ऐप की जानकारी संपादित करें और ऐप को ऑर्केस्ट्रेट करें", diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json index af6bc3d6229..4ec874c1712 100644 --- a/web/i18n/hi-IN/plugin.json +++ b/web/i18n/hi-IN/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "सभी इंटीग्रेशन", "marketplace.and": "और", "marketplace.becomePartner": "भागीदार बनें", + "marketplace.carousel.goToPage": "पृष्ठ {{page}} पर जाएं", + "marketplace.carousel.scrollNext": "अगला पृष्ठ", + "marketplace.carousel.scrollPrevious": "पिछला पृष्ठ", + "marketplace.creatorProfile.breadcrumbLabel": "ब्रेडक्रम्ब", + "marketplace.creatorProfile.creations": "रचनाएँ", + "marketplace.creatorProfile.empty": "अभी कोई रचना नहीं।", + "marketplace.creatorProfile.home": "Marketplace होम", + "marketplace.creatorProfile.onTheWeb": "वेब पर", + "marketplace.creatorProfile.organization": "संगठन", + "marketplace.creatorProfile.searchPlaceholder": "प्लगिन और टेम्पलेट खोजें", + "marketplace.creatorProfile.sort.asc": "बढ़ते क्रम में", + "marketplace.creatorProfile.sort.createdAt": "हाल ही में बनाया गया", + "marketplace.creatorProfile.sort.desc": "घटते क्रम में", + "marketplace.creatorProfile.sort.popularity": "लोकप्रियता", + "marketplace.creatorProfile.sort.updatedAt": "हाल ही में अपडेट किया गया", + "marketplace.creatorProfile.sortBy": "क्रमबद्ध करें", + "marketplace.creatorProfile.title": "क्रिएटर प्रोफ़ाइल", + "marketplace.creatorProfile.type.plugin": "प्लगिन", + "marketplace.creatorProfile.type.template": "टेम्पलेट", "marketplace.difyMarketplace": "डिफाई मार्केटप्लेस", "marketplace.discover": "खोजें", "marketplace.empower": "अपने एआई विकास को सशक्त बनाएं", + "marketplace.home.creatorCenter": "क्रिएटर केंद्र", + "marketplace.home.guide": "मार्गदर्शिका", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "खोजें। विस्तार करें। बनाएँ", + "marketplace.home.plugins": "एकीकरण", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "टेम्पलेट", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "रोकें", + "marketplace.home.trendingPlay": "चलाएं", + "marketplace.home.trendingReadMore": "और पढ़ें", + "marketplace.home.trendingReadMoreAbout": "{{title}} के बारे में और पढ़ें", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "देखें", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "लोड नहीं हो सका। कृपया पुनः प्रयास करें।", "marketplace.moreFrom": "मार्केटप्लेस से अधिक", "marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला", "marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित", "marketplace.pluginsHeroSubtitle": "अपने एआई विकास को सशक्त बनाने के लिए समुदाय द्वारा निर्मित इंटीग्रेशन का उपयोग करें।", "marketplace.pluginsHeroTitle": "खोजें। विस्तार करें। निर्माण करें।", "marketplace.pluginsResult": "{{num}} परिणाम", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "काला शहर", "marketplace.sortOption.firstReleased": "पहली बार जारी किया गया", "marketplace.sortOption.mostPopular": "सबसे लोकप्रिय", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 9842b0e73ca..d6bd34f39c7 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Kedaluwarsa dalam {{count}} hari", "license.unlimited": "Unlimited", "loading": "Memuat", + "mainNav.help.creatorCenter": "Pusat Kreator", "mainNav.help.docs": "Dokumentasi", "mainNav.help.learnDify": "Pelajari Dify", "mainNav.help.openMenu": "Buka menu bantuan", @@ -669,6 +670,7 @@ "userProfile.about": "Tentang", "userProfile.compliance": "Kepatuhan", "userProfile.contactUs": "Hubungi Kami", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Dukungan Email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Docs", diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json index 4b04ea96f00..349bff75538 100644 --- a/web/i18n/id-ID/permission-keys.json +++ b/web/i18n/id-ID/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Kelola konfigurasi ekstensi API", "app.access_config": "Konfigurasikan izin akses aplikasi", "app.acl.access_config": "Lihat dan kelola izin akses", + "app.acl.access_point_manage": "Lihat dan kelola titik akses", "app.acl.delete": "Hapus aplikasi", "app.acl.deploy": "Deploy aplikasi", "app.acl.edit": "Edit informasi aplikasi dan orkestrasikan aplikasi", diff --git a/web/i18n/id-ID/plugin.json b/web/i18n/id-ID/plugin.json index c924915092c..9c917d11807 100644 --- a/web/i18n/id-ID/plugin.json +++ b/web/i18n/id-ID/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Semua integrasi", "marketplace.and": "dan", "marketplace.becomePartner": "Menjadi Partner", + "marketplace.carousel.goToPage": "Buka halaman {{page}}", + "marketplace.carousel.scrollNext": "Halaman berikutnya", + "marketplace.carousel.scrollPrevious": "Halaman sebelumnya", + "marketplace.creatorProfile.breadcrumbLabel": "Jalur navigasi", + "marketplace.creatorProfile.creations": "Karya", + "marketplace.creatorProfile.empty": "Belum ada karya.", + "marketplace.creatorProfile.home": "Beranda Marketplace", + "marketplace.creatorProfile.onTheWeb": "Di web", + "marketplace.creatorProfile.organization": "Organisasi", + "marketplace.creatorProfile.searchPlaceholder": "Cari plugin dan template", + "marketplace.creatorProfile.sort.asc": "Urutkan menaik", + "marketplace.creatorProfile.sort.createdAt": "Baru dibuat", + "marketplace.creatorProfile.sort.desc": "Urutkan menurun", + "marketplace.creatorProfile.sort.popularity": "Popularitas", + "marketplace.creatorProfile.sort.updatedAt": "Baru diperbarui", + "marketplace.creatorProfile.sortBy": "Urutkan berdasarkan", + "marketplace.creatorProfile.title": "Profil kreator", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Template", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Menemukan", "marketplace.empower": "Berdayakan pengembangan AI Anda", + "marketplace.home.creatorCenter": "Pusat Kreator", + "marketplace.home.guide": "Panduan", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Temukan. Perluas. Bangun", + "marketplace.home.plugins": "Plugin", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Templat", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Jeda", + "marketplace.home.trendingPlay": "Putar", + "marketplace.home.trendingReadMore": "Baca selengkapnya", + "marketplace.home.trendingReadMoreAbout": "Baca selengkapnya tentang {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Lihat", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Gagal memuat. Silakan coba lagi.", "marketplace.moreFrom": "Selengkapnya dari Marketplace", "marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan", "marketplace.partnerTip": "Diverifikasi oleh partner Dify", "marketplace.pluginsHeroSubtitle": "Gunakan integrasi buatan komunitas untuk mendukung pengembangan AI Anda.", "marketplace.pluginsHeroTitle": "Temukan. Perluas. Bangun.", "marketplace.pluginsResult": "hasil {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Urutkan berdasarkan", "marketplace.sortOption.firstReleased": "Pertama Dirilis", "marketplace.sortOption.mostPopular": "Paling Populer", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 9d39d111543..46657b8eae8 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Scadenza tra {{count}} giorni", "license.unlimited": "Illimitato", "loading": "Caricamento", + "mainNav.help.creatorCenter": "Centro creatori", "mainNav.help.docs": "Documentazione", "mainNav.help.learnDify": "Impara Dify", "mainNav.help.openMenu": "Apri menu di aiuto", @@ -669,6 +670,7 @@ "userProfile.about": "Informazioni", "userProfile.compliance": "Conformità", "userProfile.contactUs": "Contattaci", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Supporto Email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Aiuto", diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json index 899adce084b..b5f3ebf8094 100644 --- a/web/i18n/it-IT/permission-keys.json +++ b/web/i18n/it-IT/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Gestisci la configurazione delle estensioni API", "app.access_config": "Configura i permessi di accesso all'app", "app.acl.access_config": "Visualizza e gestisci i permessi di accesso", + "app.acl.access_point_manage": "Visualizza e gestisci i punti di accesso", "app.acl.delete": "Elimina app", "app.acl.deploy": "Distribuisci app", "app.acl.edit": "Modifica le informazioni e orchestra l'app", diff --git a/web/i18n/it-IT/plugin.json b/web/i18n/it-IT/plugin.json index 95acde561a4..f3f2ff975a1 100644 --- a/web/i18n/it-IT/plugin.json +++ b/web/i18n/it-IT/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Tutte le integrazioni", "marketplace.and": "e", "marketplace.becomePartner": "Diventa un partner", + "marketplace.carousel.goToPage": "Vai alla pagina {{page}}", + "marketplace.carousel.scrollNext": "Pagina successiva", + "marketplace.carousel.scrollPrevious": "Pagina precedente", + "marketplace.creatorProfile.breadcrumbLabel": "Percorso di navigazione", + "marketplace.creatorProfile.creations": "Creazioni", + "marketplace.creatorProfile.empty": "Nessuna creazione al momento.", + "marketplace.creatorProfile.home": "Home del Marketplace", + "marketplace.creatorProfile.onTheWeb": "Sul web", + "marketplace.creatorProfile.organization": "Organizzazione", + "marketplace.creatorProfile.searchPlaceholder": "Cerca plugin e modelli", + "marketplace.creatorProfile.sort.asc": "Ordine crescente", + "marketplace.creatorProfile.sort.createdAt": "Creati di recente", + "marketplace.creatorProfile.sort.desc": "Ordine decrescente", + "marketplace.creatorProfile.sort.popularity": "Popolarità", + "marketplace.creatorProfile.sort.updatedAt": "Aggiornati di recente", + "marketplace.creatorProfile.sortBy": "Ordina per", + "marketplace.creatorProfile.title": "Profilo del creator", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Modello", "marketplace.difyMarketplace": "Mercato Dify", "marketplace.discover": "Scoprire", "marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale", + "marketplace.home.creatorCenter": "Centro creatori", + "marketplace.home.guide": "Guida", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Scopri. Estendi. Crea", + "marketplace.home.plugins": "Plugin", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Modelli", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pausa", + "marketplace.home.trendingPlay": "Riproduci", + "marketplace.home.trendingReadMore": "Scopri di più", + "marketplace.home.trendingReadMoreAbout": "Scopri di più su {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Visualizza", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Caricamento non riuscito. Riprova.", "marketplace.moreFrom": "Altro da Marketplace", "marketplace.noPluginFound": "Nessuna integrazione trovata", "marketplace.partnerTip": "Verificato da un partner Dify", "marketplace.pluginsHeroSubtitle": "Usa integrazioni create dalla community per potenziare lo sviluppo della tua IA.", "marketplace.pluginsHeroTitle": "Scopri. Estendi. Costruisci.", "marketplace.pluginsResult": "{{num}} risultati", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Ordina per", "marketplace.sortOption.firstReleased": "Prima pubblicazione", "marketplace.sortOption.mostPopular": "I più popolari", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index b0c2886fb9e..9ea1499d8e7 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "有効期限 {{count}} 日", "license.unlimited": "無制限", "loading": "読み込み中", + "mainNav.help.creatorCenter": "クリエイターセンター", "mainNav.help.docs": "ドキュメント", "mainNav.help.learnDify": "Difyを学ぶ", "mainNav.help.openMenu": "ヘルプメニューを開く", @@ -669,6 +670,7 @@ "userProfile.about": "Dify について", "userProfile.compliance": "コンプライアンス", "userProfile.contactUs": "お問い合わせ", + "userProfile.discord": "Discord", "userProfile.emailSupport": "サポート", "userProfile.github": "GitHub", "userProfile.helpCenter": "ドキュメントを見る", diff --git a/web/i18n/ja-JP/permission-keys.json b/web/i18n/ja-JP/permission-keys.json index 1b0c567f0e8..53033e2dc10 100644 --- a/web/i18n/ja-JP/permission-keys.json +++ b/web/i18n/ja-JP/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "API拡張設定を管理", "app.access_config": "アプリアクセス権限を設定", "app.acl.access_config": "アクセス権限の表示と管理", + "app.acl.access_point_manage": "アクセスポイントの表示と管理", "app.acl.delete": "アプリを削除", "app.acl.deploy": "アプリをデプロイ", "app.acl.edit": "アプリ情報の編集とアプリのオーケストレーション", diff --git a/web/i18n/ja-JP/plugin.json b/web/i18n/ja-JP/plugin.json index da8d706f5a4..2b32e6b9708 100644 --- a/web/i18n/ja-JP/plugin.json +++ b/web/i18n/ja-JP/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "すべてのインテグレーション", "marketplace.and": "と", "marketplace.becomePartner": "パートナーになる", + "marketplace.carousel.goToPage": "{{page}}ページへ移動", + "marketplace.carousel.scrollNext": "次のページ", + "marketplace.carousel.scrollPrevious": "前のページ", + "marketplace.creatorProfile.breadcrumbLabel": "パンくずリスト", + "marketplace.creatorProfile.creations": "作品", + "marketplace.creatorProfile.empty": "作品はまだありません。", + "marketplace.creatorProfile.home": "Marketplace ホーム", + "marketplace.creatorProfile.onTheWeb": "ウェブサイト", + "marketplace.creatorProfile.organization": "組織", + "marketplace.creatorProfile.searchPlaceholder": "プラグインとテンプレートを検索", + "marketplace.creatorProfile.sort.asc": "昇順に並べ替え", + "marketplace.creatorProfile.sort.createdAt": "作成日時", + "marketplace.creatorProfile.sort.desc": "降順に並べ替え", + "marketplace.creatorProfile.sort.popularity": "人気順", + "marketplace.creatorProfile.sort.updatedAt": "更新日時", + "marketplace.creatorProfile.sortBy": "並び順", + "marketplace.creatorProfile.title": "クリエイタープロフィール", + "marketplace.creatorProfile.type.plugin": "プラグイン", + "marketplace.creatorProfile.type.template": "テンプレート", "marketplace.difyMarketplace": "Dify マーケットプレイス", "marketplace.discover": "探索", "marketplace.empower": "AI 開発をサポートする", + "marketplace.home.creatorCenter": "クリエイターセンター", + "marketplace.home.guide": "ガイド", + "marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。", + "marketplace.home.heroTitle": "見つける。拡張する。構築する", + "marketplace.home.plugins": "プラグイン", + "marketplace.home.searchPlaceholder": "プラグインまたはテンプレートを検索", + "marketplace.home.templates": "テンプレート", + "marketplace.home.trendingByCreator": "{{creator}} 作成", + "marketplace.home.trendingDescription": "実際の利用状況に基づく人気プラグインを2週間ごとに更新。ワークスペースでの実行数によるランキングで、有料掲載や編集部による選定はありません。", + "marketplace.home.trendingPaginationLabel": "トレンドページ", + "marketplace.home.trendingPause": "一時停止", + "marketplace.home.trendingPlay": "再生", + "marketplace.home.trendingReadMore": "続きを読む", + "marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む", + "marketplace.home.trendingTitle": "みんながインストールしているプラグイン", + "marketplace.home.trendingView": "表示", + "marketplace.languages": "言語フィルタ", + "marketplace.loadError": "読み込みに失敗しました。もう一度お試しください。", "marketplace.moreFrom": "マーケットプレイスからのさらなる情報", "marketplace.noPluginFound": "インテグレーションが見つかりません", "marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています", "marketplace.pluginsHeroSubtitle": "コミュニティ製のインテグレーションを活用して、AI 開発を強化しましょう。", "marketplace.pluginsHeroTitle": "発見する。拡張する。構築する。", "marketplace.pluginsResult": "{{num}} 件の結果", + "marketplace.searchFilterLanguage": "言語を検索", "marketplace.sortBy": "並べ替え", "marketplace.sortOption.firstReleased": "リリース順", "marketplace.sortOption.mostPopular": "人気順", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index a39e9ed2b6d..8b6330fd9a7 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "{{count}}일 후에 만료", "license.unlimited": "무제한", "loading": "로딩 중", + "mainNav.help.creatorCenter": "크리에이터 센터", "mainNav.help.docs": "문서", "mainNav.help.learnDify": "Dify 배우기", "mainNav.help.openMenu": "도움말 메뉴 열기", @@ -669,6 +670,7 @@ "userProfile.about": "Dify 소개", "userProfile.compliance": "컴플라이언스", "userProfile.contactUs": "문의하기", + "userProfile.discord": "Discord", "userProfile.emailSupport": "이메일 지원", "userProfile.github": "깃허브", "userProfile.helpCenter": "도움말 센터", diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json index 3a9981a602a..45517f6acb9 100644 --- a/web/i18n/ko-KR/permission-keys.json +++ b/web/i18n/ko-KR/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "API 확장 구성 관리", "app.access_config": "앱 접근 권한 구성", "app.acl.access_config": "접근 권한 보기 및 관리", + "app.acl.access_point_manage": "액세스 지점 보기 및 관리", "app.acl.delete": "앱 삭제", "app.acl.deploy": "앱 배포", "app.acl.edit": "앱 정보 편집 및 앱 오케스트레이션", diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json index c0f9b145f9d..3ce92d3cf39 100644 --- a/web/i18n/ko-KR/plugin.json +++ b/web/i18n/ko-KR/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "모든 플러그인", "marketplace.and": "그리고", "marketplace.becomePartner": "파트너 되기", + "marketplace.carousel.goToPage": "{{page}}페이지로 이동", + "marketplace.carousel.scrollNext": "다음 페이지", + "marketplace.carousel.scrollPrevious": "이전 페이지", + "marketplace.creatorProfile.breadcrumbLabel": "탐색 경로", + "marketplace.creatorProfile.creations": "작품", + "marketplace.creatorProfile.empty": "아직 작품이 없습니다.", + "marketplace.creatorProfile.home": "Marketplace 홈", + "marketplace.creatorProfile.onTheWeb": "웹에서", + "marketplace.creatorProfile.organization": "조직", + "marketplace.creatorProfile.searchPlaceholder": "플러그인 및 템플릿 검색", + "marketplace.creatorProfile.sort.asc": "오름차순 정렬", + "marketplace.creatorProfile.sort.createdAt": "최근 생성", + "marketplace.creatorProfile.sort.desc": "내림차순 정렬", + "marketplace.creatorProfile.sort.popularity": "인기순", + "marketplace.creatorProfile.sort.updatedAt": "최근 업데이트", + "marketplace.creatorProfile.sortBy": "정렬 기준", + "marketplace.creatorProfile.title": "크리에이터 프로필", + "marketplace.creatorProfile.type.plugin": "플러그인", + "marketplace.creatorProfile.type.template": "템플릿", "marketplace.difyMarketplace": "Dify 마켓플레이스", "marketplace.discover": "발견하다", "marketplace.empower": "AI 개발 역량 강화", + "marketplace.home.creatorCenter": "크리에이터 센터", + "marketplace.home.guide": "가이드", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "발견하고, 확장하고, 구축하세요", + "marketplace.home.plugins": "플러그인", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "템플릿", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "일시정지", + "marketplace.home.trendingPlay": "재생", + "marketplace.home.trendingReadMore": "더 알아보기", + "marketplace.home.trendingReadMoreAbout": "{{title}}에 대해 더 알아보기", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "보기", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "불러오지 못했습니다. 다시 시도해 주세요.", "marketplace.moreFrom": "Marketplace 에서 더 보기", "marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.", "marketplace.partnerTip": "Dify 파트너에 의해 확인됨", "marketplace.pluginsHeroSubtitle": "커뮤니티에서 제작한 플러그인을 사용하여 AI 개발을 강화하세요.", "marketplace.pluginsHeroTitle": "발견하고. 확장하고. 구축하세요.", "marketplace.pluginsResult": "{{num}} 결과", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "정렬", "marketplace.sortOption.firstReleased": "첫 출시", "marketplace.sortOption.mostPopular": "가장 인기 있는", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index 536eba81f79..2b4a75d9f9c 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "ຈະໝົດອາຍຸໃນອີກ {{count}} ມື້", "license.unlimited": "ບໍ່ຈຳກັດ", "loading": "ກຳລັງໂຫຼດ", + "mainNav.help.creatorCenter": "ສູນຜູ້ສ້າງ", "mainNav.help.docs": "ເອກະສານປະກອບ", "mainNav.help.learnDify": "ຮຽນຮູ້ Dify", "mainNav.help.openMenu": "ເປີດເມນູຊ່ວຍເຫຼືອ", @@ -669,6 +670,7 @@ "userProfile.about": "ກ່ຽວກັບ", "userProfile.compliance": "ການປະຕິບັດຕາມກົດລະບຽບ", "userProfile.contactUs": "ຕິດຕໍ່ພວກເຮົາ", + "userProfile.discord": "Discord", "userProfile.emailSupport": "ການຊ່ວຍເຫຼືອຜ່ານອີເມວ", "userProfile.github": "GitHub", "userProfile.helpCenter": "ເບິ່ງເອກະສານ", diff --git a/web/i18n/lo-LA/permission-keys.json b/web/i18n/lo-LA/permission-keys.json index bfba51cc7e2..e04ef1f59ec 100644 --- a/web/i18n/lo-LA/permission-keys.json +++ b/web/i18n/lo-LA/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "ຈັດການການຕັ້ງຄ່າ API extension", "app.access_config": "ຕັ້ງຄ່າສິດການເຂົ້າເຖິງແອັບ", "app.acl.access_config": "ເບິ່ງ ແລະ ຈັດການສິດການເຂົ້າເຖິງ", + "app.acl.access_point_manage": "ເບິ່ງ ແລະ ຈັດການຈຸດເຂົ້າເຖິງ", "app.acl.delete": "ລຶບແອັບ", "app.acl.deploy": "ຕິດຕັ້ງແອັບ", "app.acl.edit": "ແກ້ໄຂຂໍ້ມູນແອັບ ແລະ ຈັດການລະບົບແອັບ", diff --git a/web/i18n/lo-LA/plugin.json b/web/i18n/lo-LA/plugin.json index 3d8c671471f..4e03771d312 100644 --- a/web/i18n/lo-LA/plugin.json +++ b/web/i18n/lo-LA/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "ການເຊື່ອມຕໍ່ທັງໝົດ", "marketplace.and": "ແລະ", "marketplace.becomePartner": "ເຂົ້າຮ່ວມເປັນພັດທະນາມິດ", + "marketplace.carousel.goToPage": "ໄປທີ່ໜ້າ {{page}}", + "marketplace.carousel.scrollNext": "ໜ້າຕໍ່ໄປ", + "marketplace.carousel.scrollPrevious": "ໜ້າກ່ອນໜ້າ", + "marketplace.creatorProfile.breadcrumbLabel": "ເສັ້ນທາງນຳທາງ", + "marketplace.creatorProfile.creations": "ຜົນງານ", + "marketplace.creatorProfile.empty": "ຍັງບໍ່ມີຜົນງານ.", + "marketplace.creatorProfile.home": "ໜ້າຫຼັກ Marketplace", + "marketplace.creatorProfile.onTheWeb": "ເທິງເວັບ", + "marketplace.creatorProfile.organization": "ອົງກອນ", + "marketplace.creatorProfile.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ", + "marketplace.creatorProfile.sort.asc": "ຮຽງໜ້ອຍໄປຫຼາຍ", + "marketplace.creatorProfile.sort.createdAt": "ສ້າງລ່າສຸດ", + "marketplace.creatorProfile.sort.desc": "ຮຽງຫຼາຍໄປຫາໜ້ອຍ", + "marketplace.creatorProfile.sort.popularity": "ຄວາມນິຍົມ", + "marketplace.creatorProfile.sort.updatedAt": "ອັບເດດລ່າສຸດ", + "marketplace.creatorProfile.sortBy": "ຮຽງຕາມ", + "marketplace.creatorProfile.title": "ໂປຣໄຟລ໌ຜູ້ສ້າງ", + "marketplace.creatorProfile.type.plugin": "ປລັກອິນ", + "marketplace.creatorProfile.type.template": "ແມ່ແບບ", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "ຄົ້ນຫາ", "marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ", + "marketplace.home.creatorCenter": "ສູນຜູ້ສ້າງ", + "marketplace.home.guide": "ຄູ່ມື", + "marketplace.home.heroSubtitle": "ສ້າງດ້ວຍປລັກອິນທີ່ປອດໄພ ແລະ ເຊື່ອຖືໄດ້ຫຼາຍຂຶ້ນຈາກ Dify Marketplace.", + "marketplace.home.heroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ", + "marketplace.home.plugins": "ປລັກອິນ", + "marketplace.home.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ຫຼື ແມ່ແບບ", + "marketplace.home.templates": "ແມ່ແບບ", + "marketplace.home.trendingByCreator": "ໂດຍ {{creator}}", + "marketplace.home.trendingDescription": "ຄັດເລືອກຈາກການນຳໃຊ້ຕົວຈິງ, ອັບເດດທຸກໆສອງອາທິດ. ຈັດອັນດັບຕາມການເອີ້ນໃຊ້ຕົວຈິງໃນທົ່ວທຸກ workspace — ບໍ່ມີການຈ່າຍເງິນເພື່ອໂຄສະນາ ຫຼື ການຄັດເລືອກໂດຍທີມງານ.", + "marketplace.home.trendingPaginationLabel": "ໜ້າກຳລັງນິຍົມ", + "marketplace.home.trendingPause": "ຢຸດຊົ່ວຄາວ", + "marketplace.home.trendingPlay": "ຫຼິ້ນ", + "marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ", + "marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}", + "marketplace.home.trendingTitle": "ປລັກອິນທີ່ທຸກຄົນກຳລັງຕິດຕັ້ງ", + "marketplace.home.trendingView": "ເບິ່ງ", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "ໂຫຼດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ.", "marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace", "marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່", "marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify", "marketplace.pluginsHeroSubtitle": "ນຳໃຊ້ການເຊື່ອມຕໍ່ທີ່ສ້າງໂດຍຊຸມຊົນເພື່ອຂັບເຄື່ອນການພັດທະນາ AI ຂອງທ່ານ.", "marketplace.pluginsHeroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ.", "marketplace.pluginsResult": "{{num}} ຜົນລາຍການ", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "ຈັດລຽງໂດຍ", "marketplace.sortOption.firstReleased": "ປ່ອຍທຳອິດ", "marketplace.sortOption.mostPopular": "ໄດ້ຮັບຄວາມນິຍົມສູງສຸດ", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index ada2ed37284..7dd7503027b 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expiring in {{count}} days", "license.unlimited": "Unlimited", "loading": "Loading", + "mainNav.help.creatorCenter": "Creatorcentrum", "mainNav.help.docs": "Documentatie", "mainNav.help.learnDify": "Leer Dify kennen", "mainNav.help.openMenu": "Helpmenu openen", @@ -669,6 +670,7 @@ "userProfile.about": "About", "userProfile.compliance": "Compliance", "userProfile.contactUs": "Contact Us", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Email Support", "userProfile.github": "GitHub", "userProfile.helpCenter": "View Docs", diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json index 94fdf9d182c..8266b38ae22 100644 --- a/web/i18n/nl-NL/permission-keys.json +++ b/web/i18n/nl-NL/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "API-extensieconfiguratie beheren", "app.access_config": "Toegangsrechten voor app configureren", "app.acl.access_config": "Toegangsrechten bekijken en beheren", + "app.acl.access_point_manage": "Toegangspunten bekijken en beheren", "app.acl.delete": "App verwijderen", "app.acl.deploy": "App implementeren", "app.acl.edit": "App-informatie bewerken en app orkestreren", diff --git a/web/i18n/nl-NL/plugin.json b/web/i18n/nl-NL/plugin.json index e31111c1f15..ac5a406588a 100644 --- a/web/i18n/nl-NL/plugin.json +++ b/web/i18n/nl-NL/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Alle plugins", "marketplace.and": "and", "marketplace.becomePartner": "Word partner", + "marketplace.carousel.goToPage": "Ga naar pagina {{page}}", + "marketplace.carousel.scrollNext": "Volgende pagina", + "marketplace.carousel.scrollPrevious": "Vorige pagina", + "marketplace.creatorProfile.breadcrumbLabel": "Broodkruimelnavigatie", + "marketplace.creatorProfile.creations": "Creaties", + "marketplace.creatorProfile.empty": "Nog geen creaties.", + "marketplace.creatorProfile.home": "Marketplace-startpagina", + "marketplace.creatorProfile.onTheWeb": "Op het web", + "marketplace.creatorProfile.organization": "Organisatie", + "marketplace.creatorProfile.searchPlaceholder": "Zoek plugins en sjablonen", + "marketplace.creatorProfile.sort.asc": "Oplopend sorteren", + "marketplace.creatorProfile.sort.createdAt": "Recent gemaakt", + "marketplace.creatorProfile.sort.desc": "Aflopend sorteren", + "marketplace.creatorProfile.sort.popularity": "Populariteit", + "marketplace.creatorProfile.sort.updatedAt": "Recent bijgewerkt", + "marketplace.creatorProfile.sortBy": "Sorteren op", + "marketplace.creatorProfile.title": "Makerprofiel", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Sjabloon", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Discover", "marketplace.empower": "Empower your AI development", + "marketplace.home.creatorCenter": "Creatorcentrum", + "marketplace.home.guide": "Handleiding", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Ontdek. Breid uit. Bouw", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Sjablonen", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pauzeren", + "marketplace.home.trendingPlay": "Afspelen", + "marketplace.home.trendingReadMore": "Lees meer", + "marketplace.home.trendingReadMoreAbout": "Lees meer over {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Bekijken", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Laden mislukt. Probeer het opnieuw.", "marketplace.moreFrom": "More from Marketplace", "marketplace.noPluginFound": "Geen plugin gevonden", "marketplace.partnerTip": "Verified by a Dify partner", "marketplace.pluginsHeroSubtitle": "Gebruik door de community gebouwde plugins om je AI-ontwikkeling te versterken.", "marketplace.pluginsHeroTitle": "Ontdek. Breid uit. Bouw.", "marketplace.pluginsResult": "{{num}} results", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sort by", "marketplace.sortOption.firstReleased": "First Released", "marketplace.sortOption.mostPopular": "Most Popular", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 9f0b2616413..93bd23986c7 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Wygasa za {{count}} dni", "license.unlimited": "Nieograniczony", "loading": "Ładowanie", + "mainNav.help.creatorCenter": "Centrum twórców", "mainNav.help.docs": "Dokumentacja", "mainNav.help.learnDify": "Poznaj Dify", "mainNav.help.openMenu": "Otwórz menu pomocy", @@ -669,6 +670,7 @@ "userProfile.about": "O", "userProfile.compliance": "Zgodność", "userProfile.contactUs": "Skontaktuj się z nami", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Wsparcie e-mail", "userProfile.github": "GitHub", "userProfile.helpCenter": "Pomoc", diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json index 55619735f16..392f470914e 100644 --- a/web/i18n/pl-PL/permission-keys.json +++ b/web/i18n/pl-PL/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API", "app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", "app.acl.access_config": "Wyświetlaj uprawnienia dostępu i zarządzaj nimi", + "app.acl.access_point_manage": "Wyświetlaj punkty dostępu i zarządzaj nimi", "app.acl.delete": "Usuń aplikację", "app.acl.deploy": "Wdróż aplikację", "app.acl.edit": "Edytuj informacje o aplikacji i orkiestruj aplikację", diff --git a/web/i18n/pl-PL/plugin.json b/web/i18n/pl-PL/plugin.json index bac26740194..31f7d70b715 100644 --- a/web/i18n/pl-PL/plugin.json +++ b/web/i18n/pl-PL/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Wszystkie integracje", "marketplace.and": "i", "marketplace.becomePartner": "Zostań partnerem", + "marketplace.carousel.goToPage": "Przejdź do strony {{page}}", + "marketplace.carousel.scrollNext": "Następna strona", + "marketplace.carousel.scrollPrevious": "Poprzednia strona", + "marketplace.creatorProfile.breadcrumbLabel": "Ścieżka nawigacji", + "marketplace.creatorProfile.creations": "Twórczość", + "marketplace.creatorProfile.empty": "Brak prac.", + "marketplace.creatorProfile.home": "Strona główna Marketplace", + "marketplace.creatorProfile.onTheWeb": "W sieci", + "marketplace.creatorProfile.organization": "Organizacja", + "marketplace.creatorProfile.searchPlaceholder": "Szukaj wtyczek i szablonów", + "marketplace.creatorProfile.sort.asc": "Sortuj rosnąco", + "marketplace.creatorProfile.sort.createdAt": "Ostatnio utworzone", + "marketplace.creatorProfile.sort.desc": "Sortuj malejąco", + "marketplace.creatorProfile.sort.popularity": "Popularność", + "marketplace.creatorProfile.sort.updatedAt": "Ostatnio zaktualizowane", + "marketplace.creatorProfile.sortBy": "Sortuj według", + "marketplace.creatorProfile.title": "Profil twórcy", + "marketplace.creatorProfile.type.plugin": "Wtyczka", + "marketplace.creatorProfile.type.template": "Szablon", "marketplace.difyMarketplace": "Rynek Dify", "marketplace.discover": "Odkryć", "marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji", + "marketplace.home.creatorCenter": "Centrum twórców", + "marketplace.home.guide": "Przewodnik", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Odkrywaj. Rozszerzaj. Twórz", + "marketplace.home.plugins": "Integracje", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Szablony", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Wstrzymaj", + "marketplace.home.trendingPlay": "Odtwórz", + "marketplace.home.trendingReadMore": "Czytaj więcej", + "marketplace.home.trendingReadMoreAbout": "Czytaj więcej o {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Zobacz", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Nie udało się załadować. Spróbuj ponownie.", "marketplace.moreFrom": "Więcej z Marketplace", "marketplace.noPluginFound": "Nie znaleziono integracji", "marketplace.partnerTip": "Zweryfikowane przez partnera Dify", "marketplace.pluginsHeroSubtitle": "Korzystaj z integracji tworzonych przez społeczność, aby wspierać rozwój swojej sztucznej inteligencji.", "marketplace.pluginsHeroTitle": "Odkrywaj. Rozszerzaj. Twórz.", "marketplace.pluginsResult": "{{num}} wyniki", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Czarne miasto", "marketplace.sortOption.firstReleased": "Po raz pierwszy wydany", "marketplace.sortOption.mostPopular": "Najpopularniejsze", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index e5b19972671..3ac10ee2acb 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expirando em {{count}} dias", "license.unlimited": "Ilimitado", "loading": "Carregando", + "mainNav.help.creatorCenter": "Central do criador", "mainNav.help.docs": "Documentação", "mainNav.help.learnDify": "Aprenda Dify", "mainNav.help.openMenu": "Abrir menu de ajuda", @@ -669,6 +670,7 @@ "userProfile.about": "Sobre", "userProfile.compliance": "Conformidade", "userProfile.contactUs": "Contate-Nos", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Suporte por e-mail", "userProfile.github": "GitHub", "userProfile.helpCenter": "Ajuda", diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json index 32dda95b517..36dd03d5719 100644 --- a/web/i18n/pt-BR/permission-keys.json +++ b/web/i18n/pt-BR/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Gerenciar configuração de extensão de API", "app.access_config": "Configurar permissões de acesso ao aplicativo", "app.acl.access_config": "Visualizar e gerenciar permissões de acesso", + "app.acl.access_point_manage": "Visualizar e gerenciar pontos de acesso", "app.acl.delete": "Excluir aplicativo", "app.acl.deploy": "Implantar aplicativo", "app.acl.edit": "Editar informações e orquestrar o aplicativo", diff --git a/web/i18n/pt-BR/plugin.json b/web/i18n/pt-BR/plugin.json index 5ea2f9b4f44..da9710427db 100644 --- a/web/i18n/pt-BR/plugin.json +++ b/web/i18n/pt-BR/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Todas as integrações", "marketplace.and": "e", "marketplace.becomePartner": "Torne-se um parceiro", + "marketplace.carousel.goToPage": "Ir para a página {{page}}", + "marketplace.carousel.scrollNext": "Próxima página", + "marketplace.carousel.scrollPrevious": "Página anterior", + "marketplace.creatorProfile.breadcrumbLabel": "Navegação estrutural", + "marketplace.creatorProfile.creations": "Criações", + "marketplace.creatorProfile.empty": "Nenhuma criação ainda.", + "marketplace.creatorProfile.home": "Página inicial do Marketplace", + "marketplace.creatorProfile.onTheWeb": "Na web", + "marketplace.creatorProfile.organization": "Organização", + "marketplace.creatorProfile.searchPlaceholder": "Pesquisar plugins e modelos", + "marketplace.creatorProfile.sort.asc": "Ordenar crescente", + "marketplace.creatorProfile.sort.createdAt": "Criado recentemente", + "marketplace.creatorProfile.sort.desc": "Ordenar decrescente", + "marketplace.creatorProfile.sort.popularity": "Popularidade", + "marketplace.creatorProfile.sort.updatedAt": "Atualizado recentemente", + "marketplace.creatorProfile.sortBy": "Ordenar por", + "marketplace.creatorProfile.title": "Perfil do criador", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Modelo", "marketplace.difyMarketplace": "Mercado Dify", "marketplace.discover": "Descobrir", "marketplace.empower": "Capacite seu desenvolvimento de IA", + "marketplace.home.creatorCenter": "Central do criador", + "marketplace.home.guide": "Guia", + "marketplace.home.heroSubtitle": "Crie com plugins mais seguros e confiáveis do Dify Marketplace.", + "marketplace.home.heroTitle": "Descubra. Expanda. Crie", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Buscar plugins ou modelos", + "marketplace.home.templates": "Modelos", + "marketplace.home.trendingByCreator": "por {{creator}}", + "marketplace.home.trendingDescription": "Destaques por uso real, atualizados a cada duas semanas. Classificados pelas execuções reais nos espaços de trabalho — sem promoção paga ou seleção editorial.", + "marketplace.home.trendingPaginationLabel": "Páginas em alta", + "marketplace.home.trendingPause": "Pausar", + "marketplace.home.trendingPlay": "Reproduzir", + "marketplace.home.trendingReadMore": "Leia mais", + "marketplace.home.trendingReadMoreAbout": "Leia mais sobre {{title}}", + "marketplace.home.trendingTitle": "Os plugins que todos estão instalando", + "marketplace.home.trendingView": "Ver", + "marketplace.languages": "Idiomas", + "marketplace.loadError": "Falha ao carregar. Tente novamente.", "marketplace.moreFrom": "Mais do Marketplace", "marketplace.noPluginFound": "Nenhuma integração encontrada", "marketplace.partnerTip": "Verificado por um parceiro da Dify", "marketplace.pluginsHeroSubtitle": "Use integrações criadas pela comunidade para impulsionar seu desenvolvimento de IA.", "marketplace.pluginsHeroTitle": "Descubra. Estenda. Construa.", "marketplace.pluginsResult": "{{num}} resultados", + "marketplace.searchFilterLanguage": "Pesquisar idioma", "marketplace.sortBy": "Ordenar por", "marketplace.sortOption.firstReleased": "Lançado pela primeira vez", "marketplace.sortOption.mostPopular": "Mais popular", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index c44adecf3cb..bd635f885ad 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expiră în {{count}} zile", "license.unlimited": "Nelimitat", "loading": "Se încarcă", + "mainNav.help.creatorCenter": "Centrul creatorilor", "mainNav.help.docs": "Documentație", "mainNav.help.learnDify": "Învață Dify", "mainNav.help.openMenu": "Deschide meniul de ajutor", @@ -669,6 +670,7 @@ "userProfile.about": "Despre", "userProfile.compliance": "Conformitate", "userProfile.contactUs": "Contactați-ne", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Suport prin email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Ajutor", diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json index 2610e185492..73225f7cd60 100644 --- a/web/i18n/ro-RO/permission-keys.json +++ b/web/i18n/ro-RO/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Gestionează configurația extensiei API", "app.access_config": "Configurează permisiunile de acces ale aplicației", "app.acl.access_config": "Vizualizează și gestionează permisiunile de acces", + "app.acl.access_point_manage": "Vizualizează și gestionează punctele de acces", "app.acl.delete": "Șterge aplicația", "app.acl.deploy": "Implementează aplicația", "app.acl.edit": "Editează informațiile aplicației și orchestrează aplicația", diff --git a/web/i18n/ro-RO/plugin.json b/web/i18n/ro-RO/plugin.json index 9f0820d4395..a02da38423e 100644 --- a/web/i18n/ro-RO/plugin.json +++ b/web/i18n/ro-RO/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Toate pluginurile", "marketplace.and": "și", "marketplace.becomePartner": "Deveniți partener", + "marketplace.carousel.goToPage": "Mergi la pagina {{page}}", + "marketplace.carousel.scrollNext": "Pagina următoare", + "marketplace.carousel.scrollPrevious": "Pagina anterioară", + "marketplace.creatorProfile.breadcrumbLabel": "Fir de navigare", + "marketplace.creatorProfile.creations": "Creații", + "marketplace.creatorProfile.empty": "Nicio creație încă.", + "marketplace.creatorProfile.home": "Pagina principală Marketplace", + "marketplace.creatorProfile.onTheWeb": "Pe web", + "marketplace.creatorProfile.organization": "Organizație", + "marketplace.creatorProfile.searchPlaceholder": "Caută pluginuri și șabloane", + "marketplace.creatorProfile.sort.asc": "Sortare crescătoare", + "marketplace.creatorProfile.sort.createdAt": "Create recent", + "marketplace.creatorProfile.sort.desc": "Sortare descrescătoare", + "marketplace.creatorProfile.sort.popularity": "Popularitate", + "marketplace.creatorProfile.sort.updatedAt": "Actualizate recent", + "marketplace.creatorProfile.sortBy": "Sortează după", + "marketplace.creatorProfile.title": "Profilul creatorului", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Șablon", "marketplace.difyMarketplace": "Piața Dify", "marketplace.discover": "Descoperi", "marketplace.empower": "Îmbunătățește-ți dezvoltarea AI", + "marketplace.home.creatorCenter": "Centrul creatorilor", + "marketplace.home.guide": "Ghid", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Descoperă. Extinde. Construiește", + "marketplace.home.plugins": "Plugin-uri", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Șabloane", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pauză", + "marketplace.home.trendingPlay": "Redare", + "marketplace.home.trendingReadMore": "Citește mai mult", + "marketplace.home.trendingReadMoreAbout": "Citește mai mult despre {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Vezi", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Încărcarea a eșuat. Încercați din nou.", "marketplace.moreFrom": "Mai multe din Marketplace", "marketplace.noPluginFound": "Nu s-a găsit niciun plugin", "marketplace.partnerTip": "Verificat de un partener Dify", "marketplace.pluginsHeroSubtitle": "Folosiți pluginuri create de comunitate pentru a vă alimenta dezvoltarea AI.", "marketplace.pluginsHeroTitle": "Descoperă. Extinde. Construiește.", "marketplace.pluginsResult": "{{num}} rezultate", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sortează după", "marketplace.sortOption.firstReleased": "Prima lansare", "marketplace.sortOption.mostPopular": "Cele mai populare", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 06832cdc80f..0e8da4c9539 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Срок действия истекает через {{count}} дней", "license.unlimited": "Неограниченный", "loading": "Загрузка", + "mainNav.help.creatorCenter": "Центр авторов", "mainNav.help.docs": "Документация", "mainNav.help.learnDify": "Изучить Dify", "mainNav.help.openMenu": "Открыть меню помощи", @@ -669,6 +670,7 @@ "userProfile.about": "О нас", "userProfile.compliance": "Соблюдение", "userProfile.contactUs": "Свяжитесь с нами", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Поддержка по электронной почте", "userProfile.github": "ГитХаб", "userProfile.helpCenter": "Помощь", diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json index 574f0e96add..bd986d65e95 100644 --- a/web/i18n/ru-RU/permission-keys.json +++ b/web/i18n/ru-RU/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Управление конфигурацией API-расширений", "app.access_config": "Настройка прав доступа к приложению", "app.acl.access_config": "Просмотр и управление правами доступа", + "app.acl.access_point_manage": "Просмотр и управление точками доступа", "app.acl.delete": "Удаление приложения", "app.acl.deploy": "Развертывание приложения", "app.acl.edit": "Редактирование информации о приложении и оркестрация приложения", diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json index cc2b798f782..3b886c0ff4b 100644 --- a/web/i18n/ru-RU/plugin.json +++ b/web/i18n/ru-RU/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Все плагины", "marketplace.and": "и", "marketplace.becomePartner": "Стать партнёром", + "marketplace.carousel.goToPage": "Перейти на страницу {{page}}", + "marketplace.carousel.scrollNext": "Следующая страница", + "marketplace.carousel.scrollPrevious": "Предыдущая страница", + "marketplace.creatorProfile.breadcrumbLabel": "Навигационная цепочка", + "marketplace.creatorProfile.creations": "Работы", + "marketplace.creatorProfile.empty": "Пока нет работ.", + "marketplace.creatorProfile.home": "Главная Marketplace", + "marketplace.creatorProfile.onTheWeb": "В интернете", + "marketplace.creatorProfile.organization": "Организация", + "marketplace.creatorProfile.searchPlaceholder": "Поиск плагинов и шаблонов", + "marketplace.creatorProfile.sort.asc": "По возрастанию", + "marketplace.creatorProfile.sort.createdAt": "Недавно создано", + "marketplace.creatorProfile.sort.desc": "По убыванию", + "marketplace.creatorProfile.sort.popularity": "Популярность", + "marketplace.creatorProfile.sort.updatedAt": "Недавно обновлено", + "marketplace.creatorProfile.sortBy": "Сортировать", + "marketplace.creatorProfile.title": "Профиль автора", + "marketplace.creatorProfile.type.plugin": "Плагин", + "marketplace.creatorProfile.type.template": "Шаблон", "marketplace.difyMarketplace": "Торговая площадка Dify", "marketplace.discover": "Обнаруживать", "marketplace.empower": "Расширьте возможности разработки ИИ", + "marketplace.home.creatorCenter": "Центр авторов", + "marketplace.home.guide": "Руководство", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Открывайте. Расширяйте. Создавайте", + "marketplace.home.plugins": "Интеграции", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Шаблоны", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Пауза", + "marketplace.home.trendingPlay": "Воспроизвести", + "marketplace.home.trendingReadMore": "Читать далее", + "marketplace.home.trendingReadMoreAbout": "Подробнее о {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Открыть", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Не удалось загрузить. Повторите попытку.", "marketplace.moreFrom": "Больше из Marketplace", "marketplace.noPluginFound": "Плагин не найден", "marketplace.partnerTip": "Подтверждено партнером Dify", "marketplace.pluginsHeroSubtitle": "Используйте плагины, созданные сообществом, чтобы ускорить разработку ИИ.", "marketplace.pluginsHeroTitle": "Открывайте. Расширяйте. Создавайте.", "marketplace.pluginsResult": "Результаты {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Черный город", "marketplace.sortOption.firstReleased": "Впервые выпущен", "marketplace.sortOption.mostPopular": "Самые популярные", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 5979a3e6743..ce710e7eb2a 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Poteče v {{count}} dneh", "license.unlimited": "Brez omejitev", "loading": "Nalaganje", + "mainNav.help.creatorCenter": "Središče za ustvarjalce", "mainNav.help.docs": "Dokumentacija", "mainNav.help.learnDify": "Spoznajte Dify", "mainNav.help.openMenu": "Odpri meni pomoči", @@ -669,6 +670,7 @@ "userProfile.about": "O nas", "userProfile.compliance": "Skladnost", "userProfile.contactUs": "Kontaktirajte nas", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Podpora po e-pošti", "userProfile.github": "GitHub", "userProfile.helpCenter": "Pomoč", diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json index 544c6f92a8d..4a49494a2c2 100644 --- a/web/i18n/sl-SI/permission-keys.json +++ b/web/i18n/sl-SI/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Upravljanje konfiguracije razširitve API", "app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", "app.acl.access_config": "Ogled in upravljanje dovoljenj za dostop", + "app.acl.access_point_manage": "Ogled in upravljanje dostopnih točk", "app.acl.delete": "Izbriši aplikacijo", "app.acl.deploy": "Uvedi aplikacijo", "app.acl.edit": "Uredi podatke o aplikaciji in orkestriraj aplikacijo", diff --git a/web/i18n/sl-SI/plugin.json b/web/i18n/sl-SI/plugin.json index 854abf2439d..959425d379f 100644 --- a/web/i18n/sl-SI/plugin.json +++ b/web/i18n/sl-SI/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Vsi vtičniki", "marketplace.and": "in", "marketplace.becomePartner": "Postanite partner", + "marketplace.carousel.goToPage": "Pojdi na stran {{page}}", + "marketplace.carousel.scrollNext": "Naslednja stran", + "marketplace.carousel.scrollPrevious": "Prejšnja stran", + "marketplace.creatorProfile.breadcrumbLabel": "Drobtinice", + "marketplace.creatorProfile.creations": "Stvaritve", + "marketplace.creatorProfile.empty": "Še ni stvaritev.", + "marketplace.creatorProfile.home": "Domov Marketplace", + "marketplace.creatorProfile.onTheWeb": "Na spletu", + "marketplace.creatorProfile.organization": "Organizacija", + "marketplace.creatorProfile.searchPlaceholder": "Iskanje vtičnikov in predlog", + "marketplace.creatorProfile.sort.asc": "Razvrsti naraščajoče", + "marketplace.creatorProfile.sort.createdAt": "Nedavno ustvarjeno", + "marketplace.creatorProfile.sort.desc": "Razvrsti padajoče", + "marketplace.creatorProfile.sort.popularity": "Priljubljenost", + "marketplace.creatorProfile.sort.updatedAt": "Nedavno posodobljeno", + "marketplace.creatorProfile.sortBy": "Razvrsti po", + "marketplace.creatorProfile.title": "Profil ustvarjalca", + "marketplace.creatorProfile.type.plugin": "Vtičnik", + "marketplace.creatorProfile.type.template": "Predloga", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Odkrijte", "marketplace.empower": "Okrepite svoj razvoj AI", + "marketplace.home.creatorCenter": "Središče za ustvarjalce", + "marketplace.home.guide": "Vodnik", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Odkrijte. Razširite. Ustvarite", + "marketplace.home.plugins": "Integracije", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Predloge", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Premor", + "marketplace.home.trendingPlay": "Predvajaj", + "marketplace.home.trendingReadMore": "Preberi več", + "marketplace.home.trendingReadMoreAbout": "Preberi več o {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Ogled", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Nalaganje ni uspelo. Poskusite znova.", "marketplace.moreFrom": "Več iz tržnice", "marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.", "marketplace.partnerTip": "Potrjeno s strani partnerja Dify", "marketplace.pluginsHeroSubtitle": "Uporabite vtičnike, ki jih je ustvarila skupnost, za pospešitev vašega razvoja AI.", "marketplace.pluginsHeroTitle": "Odkrijte. Razširite. Gradite.", "marketplace.pluginsResult": "{{num}} rezultati", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Razvrsti po", "marketplace.sortOption.firstReleased": "Prvič izdan", "marketplace.sortOption.mostPopular": "Najbolj priljubljeno", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index e85c29ff2eb..b389ffda54c 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "หมดอายุใน {{count}} วัน", "license.unlimited": "ไม่มีขีดจำกัด", "loading": "กำลังโหลด", + "mainNav.help.creatorCenter": "ศูนย์ครีเอเตอร์", "mainNav.help.docs": "เอกสาร", "mainNav.help.learnDify": "เรียนรู้ Dify", "mainNav.help.openMenu": "เปิดเมนูช่วยเหลือ", @@ -669,6 +670,7 @@ "userProfile.about": "ประมาณ", "userProfile.compliance": "การปฏิบัติตามข้อกำหนด", "userProfile.contactUs": "ติดต่อเรา", + "userProfile.discord": "Discord", "userProfile.emailSupport": "การสนับสนุนทางอีเมล", "userProfile.github": "GitHub", "userProfile.helpCenter": "วิธีใช้", diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json index b7b9854abf0..0ad4047ef26 100644 --- a/web/i18n/th-TH/permission-keys.json +++ b/web/i18n/th-TH/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API", "app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", "app.acl.access_config": "ดูและจัดการสิทธิ์การเข้าถึง", + "app.acl.access_point_manage": "ดูและจัดการจุดเข้าถึง", "app.acl.delete": "ลบแอป", "app.acl.deploy": "ปรับใช้แอป", "app.acl.edit": "แก้ไขข้อมูลแอปและจัดวางแอป", diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json index 0961caf3e0e..67b64c4ded7 100644 --- a/web/i18n/th-TH/plugin.json +++ b/web/i18n/th-TH/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "ปลั๊กอินทั้งหมด", "marketplace.and": "และ", "marketplace.becomePartner": "เป็นพันธมิตร", + "marketplace.carousel.goToPage": "ไปที่หน้า {{page}}", + "marketplace.carousel.scrollNext": "หน้าถัดไป", + "marketplace.carousel.scrollPrevious": "หน้าก่อนหน้า", + "marketplace.creatorProfile.breadcrumbLabel": "เส้นทางนำทาง", + "marketplace.creatorProfile.creations": "ผลงาน", + "marketplace.creatorProfile.empty": "ยังไม่มีผลงาน", + "marketplace.creatorProfile.home": "หน้าแรก Marketplace", + "marketplace.creatorProfile.onTheWeb": "บนเว็บ", + "marketplace.creatorProfile.organization": "องค์กร", + "marketplace.creatorProfile.searchPlaceholder": "ค้นหาปลั๊กอินและเทมเพลต", + "marketplace.creatorProfile.sort.asc": "เรียงจากน้อยไปมาก", + "marketplace.creatorProfile.sort.createdAt": "สร้างล่าสุด", + "marketplace.creatorProfile.sort.desc": "เรียงจากมากไปน้อย", + "marketplace.creatorProfile.sort.popularity": "ความนิยม", + "marketplace.creatorProfile.sort.updatedAt": "อัปเดตล่าสุด", + "marketplace.creatorProfile.sortBy": "เรียงตาม", + "marketplace.creatorProfile.title": "โปรไฟล์ครีเอเตอร์", + "marketplace.creatorProfile.type.plugin": "ปลั๊กอิน", + "marketplace.creatorProfile.type.template": "เทมเพลต", "marketplace.difyMarketplace": "ตลาด Dify", "marketplace.discover": "ค้นพบ", "marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ", + "marketplace.home.creatorCenter": "ศูนย์ครีเอเตอร์", + "marketplace.home.guide": "คู่มือ", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "ค้นพบ ขยาย และสร้าง", + "marketplace.home.plugins": "ปลั๊กอิน", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "เทมเพลต", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "หยุดชั่วคราว", + "marketplace.home.trendingPlay": "เล่น", + "marketplace.home.trendingReadMore": "อ่านเพิ่มเติม", + "marketplace.home.trendingReadMoreAbout": "อ่านเพิ่มเติมเกี่ยวกับ {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "ดู", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "โหลดไม่สำเร็จ โปรดลองอีกครั้ง", "marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace", "marketplace.noPluginFound": "ไม่พบปลั๊กอิน", "marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify", "marketplace.pluginsHeroSubtitle": "ใช้ปลั๊กอินที่สร้างโดยชุมชนเพื่อเสริมพลังการพัฒนา AI ของคุณ", "marketplace.pluginsHeroTitle": "ค้นพบ ขยาย สร้าง", "marketplace.pluginsResult": "{{num}} ผลลัพธ์", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "เมืองสีดํา", "marketplace.sortOption.firstReleased": "เปิดตัวครั้งแรก", "marketplace.sortOption.mostPopular": "แห่ง", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index bfd30dc5a00..5854f0b0662 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "{{count}} gün içinde sona eriyor", "license.unlimited": "Sınırsız", "loading": "Yükleniyor", + "mainNav.help.creatorCenter": "İçerik Üretici Merkezi", "mainNav.help.docs": "Belgeler", "mainNav.help.learnDify": "Dify’ı öğrenin", "mainNav.help.openMenu": "Yardım menüsünü aç", @@ -669,6 +670,7 @@ "userProfile.about": "Hakkında", "userProfile.compliance": "Uygunluk", "userProfile.contactUs": "Bize Ulaşın", + "userProfile.discord": "Discord", "userProfile.emailSupport": "E-posta Desteği", "userProfile.github": "GitHub", "userProfile.helpCenter": "Yardım", diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json index 36ba8ec9709..781d9d00d38 100644 --- a/web/i18n/tr-TR/permission-keys.json +++ b/web/i18n/tr-TR/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "API uzantısı yapılandırmasını yönet", "app.access_config": "Uygulama erişim izinlerini yapılandır", "app.acl.access_config": "Erişim izinlerini görüntüle ve yönet", + "app.acl.access_point_manage": "Erişim noktalarını görüntüle ve yönet", "app.acl.delete": "Uygulamayı sil", "app.acl.deploy": "Uygulamayı dağıt", "app.acl.edit": "Uygulama bilgilerini düzenle ve uygulamayı orkestre et", diff --git a/web/i18n/tr-TR/plugin.json b/web/i18n/tr-TR/plugin.json index 3054c665e5e..2718093eb6e 100644 --- a/web/i18n/tr-TR/plugin.json +++ b/web/i18n/tr-TR/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Tüm eklentiler", "marketplace.and": "ve", "marketplace.becomePartner": "Partner Olun", + "marketplace.carousel.goToPage": "{{page}}. sayfaya git", + "marketplace.carousel.scrollNext": "Sonraki sayfa", + "marketplace.carousel.scrollPrevious": "Önceki sayfa", + "marketplace.creatorProfile.breadcrumbLabel": "Sayfa yolu", + "marketplace.creatorProfile.creations": "Çalışmalar", + "marketplace.creatorProfile.empty": "Henüz çalışma yok.", + "marketplace.creatorProfile.home": "Marketplace ana sayfası", + "marketplace.creatorProfile.onTheWeb": "Web'de", + "marketplace.creatorProfile.organization": "Organizasyon", + "marketplace.creatorProfile.searchPlaceholder": "Eklenti ve şablon ara", + "marketplace.creatorProfile.sort.asc": "Artan sırala", + "marketplace.creatorProfile.sort.createdAt": "Son oluşturulan", + "marketplace.creatorProfile.sort.desc": "Azalan sırala", + "marketplace.creatorProfile.sort.popularity": "Popülerlik", + "marketplace.creatorProfile.sort.updatedAt": "Son güncellenen", + "marketplace.creatorProfile.sortBy": "Sırala", + "marketplace.creatorProfile.title": "Üretici profili", + "marketplace.creatorProfile.type.plugin": "Eklenti", + "marketplace.creatorProfile.type.template": "Şablon", "marketplace.difyMarketplace": "Dify Pazar Yeri", "marketplace.discover": "Keşfet", "marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin", + "marketplace.home.creatorCenter": "İçerik Üretici Merkezi", + "marketplace.home.guide": "Kılavuz", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Keşfet. Genişlet. Oluştur", + "marketplace.home.plugins": "Eklentiler", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Şablonlar", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Duraklat", + "marketplace.home.trendingPlay": "Oynat", + "marketplace.home.trendingReadMore": "Devamını oku", + "marketplace.home.trendingReadMoreAbout": "{{title}} hakkında devamını oku", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Görüntüle", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Yüklenemedi. Lütfen tekrar deneyin.", "marketplace.moreFrom": "Pazar Yeri'nden daha fazlası", "marketplace.noPluginFound": "Eklenti bulunamadı", "marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı", "marketplace.pluginsHeroSubtitle": "Yapay zeka geliştirmenizi güçlendirmek için topluluk tarafından oluşturulan eklentileri kullanın.", "marketplace.pluginsHeroTitle": "Keşfet. Genişlet. Oluştur.", "marketplace.pluginsResult": "{{num}} sonuç", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sırala", "marketplace.sortOption.firstReleased": "İlk Çıkanlar", "marketplace.sortOption.mostPopular": "En popüler", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index 011449c67ab..113648dc0a8 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Термін дії закінчується за {{count}} днів", "license.unlimited": "Безмежний", "loading": "Завантаження", + "mainNav.help.creatorCenter": "Центр авторів", "mainNav.help.docs": "Документація", "mainNav.help.learnDify": "Вивчити Dify", "mainNav.help.openMenu": "Відкрити меню довідки", @@ -669,6 +670,7 @@ "userProfile.about": "Про нас", "userProfile.compliance": "Відповідність", "userProfile.contactUs": "Зв’яжіться з нами", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Підтримка по електронній пошті", "userProfile.github": "Гітхаб", "userProfile.helpCenter": "Довідковий центр", diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json index 861c83a4367..8cfd28d2548 100644 --- a/web/i18n/uk-UA/permission-keys.json +++ b/web/i18n/uk-UA/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Керування конфігурацією розширення API", "app.access_config": "Налаштування дозволів доступу до застосунку", "app.acl.access_config": "Переглядати дозволи доступу та керувати ними", + "app.acl.access_point_manage": "Переглядати точки доступу та керувати ними", "app.acl.delete": "Видалити застосунок", "app.acl.deploy": "Розгорнути застосунок", "app.acl.edit": "Редагувати інформацію про застосунок та оркеструвати застосунок", diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json index 630ece992cf..80dff6cdd78 100644 --- a/web/i18n/uk-UA/plugin.json +++ b/web/i18n/uk-UA/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Всі плагіни", "marketplace.and": "і", "marketplace.becomePartner": "Стати партнером", + "marketplace.carousel.goToPage": "Перейти на сторінку {{page}}", + "marketplace.carousel.scrollNext": "Наступна сторінка", + "marketplace.carousel.scrollPrevious": "Попередня сторінка", + "marketplace.creatorProfile.breadcrumbLabel": "Навігаційний ланцюжок", + "marketplace.creatorProfile.creations": "Роботи", + "marketplace.creatorProfile.empty": "Поки немає робіт.", + "marketplace.creatorProfile.home": "Головна Marketplace", + "marketplace.creatorProfile.onTheWeb": "В інтернеті", + "marketplace.creatorProfile.organization": "Організація", + "marketplace.creatorProfile.searchPlaceholder": "Пошук плагінів і шаблонів", + "marketplace.creatorProfile.sort.asc": "За зростанням", + "marketplace.creatorProfile.sort.createdAt": "Нещодавно створено", + "marketplace.creatorProfile.sort.desc": "За спаданням", + "marketplace.creatorProfile.sort.popularity": "Популярність", + "marketplace.creatorProfile.sort.updatedAt": "Нещодавно оновлено", + "marketplace.creatorProfile.sortBy": "Сортувати", + "marketplace.creatorProfile.title": "Профіль автора", + "marketplace.creatorProfile.type.plugin": "Плагін", + "marketplace.creatorProfile.type.template": "Шаблон", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Виявити", "marketplace.empower": "Розширюйте можливості розробки штучного інтелекту", + "marketplace.home.creatorCenter": "Центр авторів", + "marketplace.home.guide": "Посібник", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Відкривайте. Розширюйте. Створюйте", + "marketplace.home.plugins": "Інтеграції", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Шаблони", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Пауза", + "marketplace.home.trendingPlay": "Відтворити", + "marketplace.home.trendingReadMore": "Читати далі", + "marketplace.home.trendingReadMoreAbout": "Дізнатися більше про {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Переглянути", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Не вдалося завантажити. Спробуйте ще раз.", "marketplace.moreFrom": "Більше від Marketplace", "marketplace.noPluginFound": "Плагін не знайдено", "marketplace.partnerTip": "Перевірено партнером Dify", "marketplace.pluginsHeroSubtitle": "Використовуйте створені спільнотою плагіни для розвитку вашої розробки штучного інтелекту.", "marketplace.pluginsHeroTitle": "Відкривайте. Розширюйте. Створюйте.", "marketplace.pluginsResult": "Результати {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Чорне місто", "marketplace.sortOption.firstReleased": "Перший реліз", "marketplace.sortOption.mostPopular": "Найпопулярніших", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 267387c547e..60ecb59c3e6 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Hết hạn sau {{count}} ngày", "license.unlimited": "Vô hạn", "loading": "Đang tải", + "mainNav.help.creatorCenter": "Trung tâm nhà sáng tạo", "mainNav.help.docs": "Tài liệu", "mainNav.help.learnDify": "Tìm hiểu Dify", "mainNav.help.openMenu": "Mở menu trợ giúp", @@ -669,6 +670,7 @@ "userProfile.about": "Về chúng tôi", "userProfile.compliance": "Tuân thủ", "userProfile.contactUs": "Liên hệ với chúng tôi", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Hỗ trợ qua Email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Trung tâm trợ giúp", diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json index 1e6662a9304..2290d6362e0 100644 --- a/web/i18n/vi-VN/permission-keys.json +++ b/web/i18n/vi-VN/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "Quản lý cấu hình phần mở rộng API", "app.access_config": "Cấu hình quyền truy cập ứng dụng", "app.acl.access_config": "Xem và quản lý quyền truy cập", + "app.acl.access_point_manage": "Xem và quản lý điểm truy cập", "app.acl.delete": "Xóa ứng dụng", "app.acl.deploy": "Triển khai ứng dụng", "app.acl.edit": "Chỉnh sửa thông tin và điều phối ứng dụng", diff --git a/web/i18n/vi-VN/plugin.json b/web/i18n/vi-VN/plugin.json index 0c53d8f25c2..1967395535d 100644 --- a/web/i18n/vi-VN/plugin.json +++ b/web/i18n/vi-VN/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "Tất cả plugin", "marketplace.and": "và", "marketplace.becomePartner": "Trở thành đối tác", + "marketplace.carousel.goToPage": "Đi tới trang {{page}}", + "marketplace.carousel.scrollNext": "Trang sau", + "marketplace.carousel.scrollPrevious": "Trang trước", + "marketplace.creatorProfile.breadcrumbLabel": "Đường dẫn điều hướng", + "marketplace.creatorProfile.creations": "Tác phẩm", + "marketplace.creatorProfile.empty": "Chưa có tác phẩm nào.", + "marketplace.creatorProfile.home": "Trang chủ Marketplace", + "marketplace.creatorProfile.onTheWeb": "Trên web", + "marketplace.creatorProfile.organization": "Tổ chức", + "marketplace.creatorProfile.searchPlaceholder": "Tìm plugin và mẫu", + "marketplace.creatorProfile.sort.asc": "Sắp xếp tăng dần", + "marketplace.creatorProfile.sort.createdAt": "Tạo gần đây", + "marketplace.creatorProfile.sort.desc": "Sắp xếp giảm dần", + "marketplace.creatorProfile.sort.popularity": "Phổ biến", + "marketplace.creatorProfile.sort.updatedAt": "Cập nhật gần đây", + "marketplace.creatorProfile.sortBy": "Sắp xếp theo", + "marketplace.creatorProfile.title": "Hồ sơ nhà sáng tạo", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Mẫu", "marketplace.difyMarketplace": "Thị trường Dify", "marketplace.discover": "Khám phá", "marketplace.empower": "Hỗ trợ phát triển AI của bạn", + "marketplace.home.creatorCenter": "Trung tâm nhà sáng tạo", + "marketplace.home.guide": "Hướng dẫn", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Khám phá. Mở rộng. Xây dựng", + "marketplace.home.plugins": "Plugin", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Mẫu", + "marketplace.home.trendingByCreator": "by {{creator}}", + "marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.", + "marketplace.home.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Tạm dừng", + "marketplace.home.trendingPlay": "Phát", + "marketplace.home.trendingReadMore": "Đọc thêm", + "marketplace.home.trendingReadMoreAbout": "Đọc thêm về {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Xem", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Tải không thành công. Vui lòng thử lại.", "marketplace.moreFrom": "Các ứng dụng khác từ Marketplace", "marketplace.noPluginFound": "Không tìm thấy plugin nào", "marketplace.partnerTip": "Được xác nhận bởi một đối tác của Dify", "marketplace.pluginsHeroSubtitle": "Sử dụng các plugin do cộng đồng xây dựng để hỗ trợ phát triển AI của bạn.", "marketplace.pluginsHeroTitle": "Khám phá. Mở rộng. Xây dựng.", "marketplace.pluginsResult": "{{num}} kết quả", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Thành phố đen", "marketplace.sortOption.firstReleased": "Phát hành lần đầu tiên", "marketplace.sortOption.mostPopular": "Phổ biến nhất", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 33307528ef9..1c1e69b59cb 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "许可证还有 {{count}} 天到期", "license.unlimited": "无限制", "loading": "加载中", + "mainNav.help.creatorCenter": "创作者中心", "mainNav.help.docs": "文档", "mainNav.help.learnDify": "了解 Dify", "mainNav.help.openMenu": "打开帮助菜单", @@ -669,6 +670,7 @@ "userProfile.about": "关于", "userProfile.compliance": "合规", "userProfile.contactUs": "联系我们", + "userProfile.discord": "Discord", "userProfile.emailSupport": "邮件支持", "userProfile.github": "GitHub", "userProfile.helpCenter": "查看帮助文档", diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json index 91d9afb2cc8..db8184ee533 100644 --- a/web/i18n/zh-Hans/permission-keys.json +++ b/web/i18n/zh-Hans/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "管理API扩展", "app.access_config": "配置应用访问权限", "app.acl.access_config": "查看与管理访问权限", + "app.acl.access_point_manage": "查看与管理访问点", "app.acl.delete": "删除应用", "app.acl.deploy": "部署应用", "app.acl.edit": "编辑应用信息与编排应用", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 184b60d344f..efae41eb2a8 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -226,15 +226,53 @@ "marketplace.allPlugins": "所有集成", "marketplace.and": "和", "marketplace.becomePartner": "成为合作伙伴", + "marketplace.carousel.goToPage": "转到第 {{page}} 页", + "marketplace.carousel.scrollNext": "下一页", + "marketplace.carousel.scrollPrevious": "上一页", + "marketplace.creatorProfile.breadcrumbLabel": "面包屑导航", + "marketplace.creatorProfile.creations": "作品", + "marketplace.creatorProfile.empty": "暂无作品。", + "marketplace.creatorProfile.home": "Marketplace 首页", + "marketplace.creatorProfile.onTheWeb": "社交主页", + "marketplace.creatorProfile.organization": "组织", + "marketplace.creatorProfile.searchPlaceholder": "搜索插件和模板", + "marketplace.creatorProfile.sort.asc": "升序排列", + "marketplace.creatorProfile.sort.createdAt": "创建时间", + "marketplace.creatorProfile.sort.desc": "降序排列", + "marketplace.creatorProfile.sort.popularity": "热度", + "marketplace.creatorProfile.sort.updatedAt": "更新时间", + "marketplace.creatorProfile.sortBy": "排序", + "marketplace.creatorProfile.title": "创作者主页", + "marketplace.creatorProfile.type.plugin": "插件", + "marketplace.creatorProfile.type.template": "模板", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "探索", "marketplace.empower": "助力您的 AI 开发", + "marketplace.home.creatorCenter": "创作者中心", + "marketplace.home.guide": "指南", + "marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。", + "marketplace.home.heroTitle": "发现。扩展。构建", + "marketplace.home.plugins": "插件", + "marketplace.home.searchPlaceholder": "搜索插件或模板", + "marketplace.home.templates": "模板", + "marketplace.home.trendingByCreator": "由 {{creator}} 发布", + "marketplace.home.trendingDescription": "基于真实使用情况选出的热门插件,每两周更新一次。榜单按各工作区的实际运行次数排序,不含付费推广或编辑推荐。", + "marketplace.home.trendingPaginationLabel": "热门推荐页码", + "marketplace.home.trendingPause": "暂停", + "marketplace.home.trendingPlay": "播放", + "marketplace.home.trendingReadMore": "阅读更多", + "marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容", + "marketplace.home.trendingTitle": "大家都在安装的插件", + "marketplace.home.trendingView": "查看", + "marketplace.languages": "按语言筛选", + "marketplace.loadError": "加载失败,请重试。", "marketplace.moreFrom": "来自 Marketplace 的更多内容", "marketplace.noPluginFound": "未找到集成", "marketplace.partnerTip": "此插件由 Dify 合作伙伴认证", "marketplace.pluginsHeroSubtitle": "使用社区构建的集成助力您的 AI 开发。", "marketplace.pluginsHeroTitle": "探索 · 扩展 · 构建", "marketplace.pluginsResult": "{{num}} 个插件结果", + "marketplace.searchFilterLanguage": "搜索语言", "marketplace.sortBy": "排序方式", "marketplace.sortOption.firstReleased": "首次发布", "marketplace.sortOption.mostPopular": "最受欢迎", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 43113137c09..5805edb6382 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "將在 {{count}} 天后過期", "license.unlimited": "無限制", "loading": "載入中", + "mainNav.help.creatorCenter": "創作者中心", "mainNav.help.docs": "文件", "mainNav.help.learnDify": "學習 Dify", "mainNav.help.openMenu": "開啟幫助選單", @@ -669,6 +670,7 @@ "userProfile.about": "關於", "userProfile.compliance": "合規", "userProfile.contactUs": "聯絡我們", + "userProfile.discord": "Discord", "userProfile.emailSupport": "電子郵件支援", "userProfile.github": "GitHub", "userProfile.helpCenter": "查看幫助文件", diff --git a/web/i18n/zh-Hant/permission-keys.json b/web/i18n/zh-Hant/permission-keys.json index 43350a59ada..8a74b37f086 100644 --- a/web/i18n/zh-Hant/permission-keys.json +++ b/web/i18n/zh-Hant/permission-keys.json @@ -3,6 +3,7 @@ "api_extension.manage": "管理API擴充配置", "app.access_config": "配置應用訪問權限", "app.acl.access_config": "檢視與管理存取權限", + "app.acl.access_point_manage": "檢視與管理存取點", "app.acl.delete": "刪除應用", "app.acl.deploy": "部署應用", "app.acl.edit": "編輯應用資訊與編排應用", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index 61d44151540..e22a867d9e8 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -84,19 +84,19 @@ "autoUpdate.upgradeMode.partial": "僅選擇", "autoUpdate.upgradeModePlaceholder.exclude": "選定的插件將不會自動更新", "autoUpdate.upgradeModePlaceholder.partial": "只有選定的插件會自動更新。目前未選定任何插件,因此不會自動更新任何插件。", - "category.agents": "代理策略", - "category.all": "都", - "category.bundles": "束", + "category.agents": "Agent 策略", + "category.all": "全部", + "category.bundles": "整合包", "category.datasources": "資料來源", - "category.extensions": "擴展", + "category.extensions": "擴充功能", "category.models": "模型", "category.tools": "工具", - "category.triggers": "觸發因素", - "categorySingle.agent": "代理策略", - "categorySingle.bundle": "捆", + "category.triggers": "觸發器", + "categorySingle.agent": "Agent 策略", + "categorySingle.bundle": "整合包", "categorySingle.datasource": "資料來源", - "categorySingle.extension": "外延", - "categorySingle.model": "型", + "categorySingle.extension": "擴充功能", + "categorySingle.model": "模型", "categorySingle.tool": "工具", "categorySingle.trigger": "觸發器", "clearSearch": "清空{{label}}", @@ -226,15 +226,53 @@ "marketplace.allPlugins": "所有集成", "marketplace.and": "和", "marketplace.becomePartner": "成為合作夥伴", + "marketplace.carousel.goToPage": "轉到第 {{page}} 頁", + "marketplace.carousel.scrollNext": "下一頁", + "marketplace.carousel.scrollPrevious": "上一頁", + "marketplace.creatorProfile.breadcrumbLabel": "麵包屑導航", + "marketplace.creatorProfile.creations": "作品", + "marketplace.creatorProfile.empty": "尚無作品。", + "marketplace.creatorProfile.home": "Marketplace 首頁", + "marketplace.creatorProfile.onTheWeb": "社交主頁", + "marketplace.creatorProfile.organization": "組織", + "marketplace.creatorProfile.searchPlaceholder": "搜尋外掛和模板", + "marketplace.creatorProfile.sort.asc": "升序排列", + "marketplace.creatorProfile.sort.createdAt": "建立時間", + "marketplace.creatorProfile.sort.desc": "降序排列", + "marketplace.creatorProfile.sort.popularity": "熱度", + "marketplace.creatorProfile.sort.updatedAt": "更新時間", + "marketplace.creatorProfile.sortBy": "排序", + "marketplace.creatorProfile.title": "創作者主頁", + "marketplace.creatorProfile.type.plugin": "外掛", + "marketplace.creatorProfile.type.template": "模板", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "發現", "marketplace.empower": "為您的 AI 開發提供支援", + "marketplace.home.creatorCenter": "創作者中心", + "marketplace.home.guide": "指南", + "marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。", + "marketplace.home.heroTitle": "探索。擴展。建構", + "marketplace.home.plugins": "外掛", + "marketplace.home.searchPlaceholder": "搜尋外掛程式或範本", + "marketplace.home.templates": "範本", + "marketplace.home.trendingByCreator": "由 {{creator}} 發布", + "marketplace.home.trendingDescription": "根據真實使用情況選出的熱門外掛程式,每兩週更新一次。榜單按各工作區的實際執行次數排序,不含付費推廣或編輯推薦。", + "marketplace.home.trendingPaginationLabel": "熱門推薦頁碼", + "marketplace.home.trendingPause": "暫停", + "marketplace.home.trendingPlay": "播放", + "marketplace.home.trendingReadMore": "閱讀更多", + "marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容", + "marketplace.home.trendingTitle": "大家都在安裝的外掛程式", + "marketplace.home.trendingView": "查看", + "marketplace.languages": "按語言篩選", + "marketplace.loadError": "載入失敗,請重試。", "marketplace.moreFrom": "來自 Marketplace 的更多內容", "marketplace.noPluginFound": "未找到集成", "marketplace.partnerTip": "由 Dify 合作夥伴驗證", "marketplace.pluginsHeroSubtitle": "使用社群構建的集成來助力您的 AI 開發。", "marketplace.pluginsHeroTitle": "發現。擴展。構建。", "marketplace.pluginsResult": "{{num}} 個結果", + "marketplace.searchFilterLanguage": "搜尋語言", "marketplace.sortBy": "排序方式", "marketplace.sortOption.firstReleased": "首次發佈", "marketplace.sortOption.mostPopular": "最受歡迎", diff --git a/web/proxy.ts b/web/proxy.ts index da637a724ac..1f7248efd53 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -18,15 +18,35 @@ const EMBEDDABLE_PATH_SEGMENTS = [ '/workflow', ] const NON_EMBEDDABLE_PATH_SEGMENTS = ['/device'] -const FRAME_ANCESTORS_NONE = "frame-ancestors 'none';" +const FRAME_ANCESTORS_NONE = "'none'" const LEGACY_EDUCATION_ACTION = 'getEducationVerify' +const getHttpOrigin = (value: string | undefined) => { + if (!value) return '' + + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : '' + } catch { + return '' + } +} + const matchesPathSegment = (pathname: string, segments: string[]) => segments.some((segment) => pathname === segment || pathname.startsWith(`${segment}/`)) export const canEmbedPath = (pathname: string) => matchesPathSegment(pathname, EMBEDDABLE_PATH_SEGMENTS) +const appendFrameAncestors = (response: NextResponse, frameOrigin: string) => { + const existingCsp = response.headers.get('Content-Security-Policy') + if (existingCsp?.includes('frame-ancestors')) return + response.headers.set( + 'Content-Security-Policy', + `${existingCsp ? `${existingCsp} ` : ''}frame-ancestors ${frameOrigin};`, + ) +} + const wrapResponseWithFrameProtection = (response: NextResponse, pathname: string) => { // Published app routes are intentionally embeddable; all other routes default to clickjacking protection. const preventEmbedding = @@ -35,13 +55,7 @@ const wrapResponseWithFrameProtection = (response: NextResponse, pathname: strin if (preventEmbedding) { response.headers.set('X-Frame-Options', 'DENY') - const contentSecurityPolicy = response.headers.get('Content-Security-Policy') - response.headers.set( - 'Content-Security-Policy', - contentSecurityPolicy - ? `${contentSecurityPolicy} ${FRAME_ANCESTORS_NONE}` - : FRAME_ANCESTORS_NONE, - ) + appendFrameAncestors(response, FRAME_ANCESTORS_NONE) } return response @@ -80,6 +94,8 @@ export function proxy(request: NextRequest) { ? ' https://challenges.cloudflare.com' : '' const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}${turnstileOrigin}` + const marketplaceFrameOrigin = getHttpOrigin(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX) + const marketplaceFrameSrc = marketplaceFrameOrigin ? ` ${marketplaceFrameOrigin}` : '' const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const csp = `'nonce-${nonce}'` @@ -92,6 +108,7 @@ export function proxy(request: NextRequest) { style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList}; worker-src 'self' ${scheme_source} ${csp} ${whiteList}; media-src 'self' ${scheme_source} ${csp} ${whiteList}; + frame-src 'self' ${scheme_source} ${whiteList}${marketplaceFrameSrc}; img-src * data: blob:; font-src 'self'; object-src 'none'; diff --git a/web/public/marketplace/dify-marketplace-logo-dark.svg b/web/public/marketplace/dify-marketplace-logo-dark.svg new file mode 100644 index 00000000000..377525a94a4 --- /dev/null +++ b/web/public/marketplace/dify-marketplace-logo-dark.svg @@ -0,0 +1,19 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#E8E8E8"/> +<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#E8E8E8"/> +<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="#E8E8E8"/> +<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="#E8E8E8"/> +<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="#E8E8E8"/> +<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="#E8E8E8"/> +<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="#E8E8E8"/> +<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="#E8E8E8"/> +<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="#E8E8E8"/> +<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="#E8E8E8"/> +<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="#E8E8E8"/> +<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="#E8E8E8"/> +<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="#E8E8E8"/> +<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="#E8E8E8"/> +<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="#E8E8E8"/> +</g> +</svg> diff --git a/web/public/marketplace/dify-marketplace-logo.svg b/web/public/marketplace/dify-marketplace-logo.svg new file mode 100644 index 00000000000..bff6718c2af --- /dev/null +++ b/web/public/marketplace/dify-marketplace-logo.svg @@ -0,0 +1,19 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#0033FF"/> +<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#0033FF"/> +<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="black"/> +<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="black"/> +<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="black"/> +<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="black"/> +<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="black"/> +<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="black"/> +<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="black"/> +<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="black"/> +<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="black"/> +<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="black"/> +<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="black"/> +<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="black"/> +<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="black"/> +</g> +</svg> diff --git a/web/service/__tests__/base-request.spec.ts b/web/service/__tests__/base-request.spec.ts index c324a83cb55..0da529bc10c 100644 --- a/web/service/__tests__/base-request.spec.ts +++ b/web/service/__tests__/base-request.spec.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { + discardRegistrationSessionState, + OAUTH_REGISTRATION_GA_SENT_KEY, + REGISTRATION_SUCCESS_STORAGE_KEY, +} from '@/app/components/base/amplitude/registration-session-state' // oxlint-disable-next-line no-restricted-imports -- This spec directly tests the legacy request owner. import { request } from '../base' @@ -51,6 +56,21 @@ const createUnauthorizedResponse = () => }, ) +const createForcedLogoutResponse = () => + new Response( + JSON.stringify({ + code: 'unauthorized_and_force_logout', + message: 'This account session is no longer valid.', + status: 401, + }), + { + status: 401, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + type ClientRequestOptions = { response: Response refreshError?: Error @@ -80,9 +100,11 @@ describe('request 401 handling', () => { writable: true, configurable: true, }) + window.sessionStorage.clear() }) afterEach(() => { + discardRegistrationSessionState() Object.defineProperty(globalThis, 'location', { value: originalLocation, writable: true, @@ -103,21 +125,42 @@ describe('request 401 handling', () => { it('should preserve the current URL when a 401 response cannot be parsed', async () => { const response = new Response('not-json', { status: 401 }) arrangeClientRequest({ response }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker') await expect(request('/account/profile')).rejects.toBe(response) expect(globalThis.location.href).toBe( `https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`, ) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() expect(mocks.refreshAccessTokenOrReLogin).not.toHaveBeenCalled() }) + it('clears account A registration state before a forced reload so account B starts clean', async () => { + const response = createForcedLogoutResponse() + arrangeClientRequest({ response }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker') + window.sessionStorage.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true') + const reload = vi.fn(() => { + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(OAUTH_REGISTRATION_GA_SENT_KEY)).toBeNull() + }) + globalThis.location.reload = reload + + await expect(request('/account/profile')).rejects.toBe(response) + + expect(reload).toHaveBeenCalledOnce() + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-b-marker') + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('account-b-marker') + }) + it('should preserve the current URL when token refresh fails', async () => { const response = createUnauthorizedResponse() arrangeClientRequest({ response, refreshError: new Error('refresh failed'), }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker') await expect(request('/account/profile')).rejects.toBe(response) @@ -125,5 +168,16 @@ describe('request 401 handling', () => { expect(globalThis.location.href).toBe( `https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`, ) + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + }) + + it('does not clear console registration state for a public-app 401 redirect', async () => { + const response = createUnauthorizedResponse() + arrangeClientRequest({ response }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'console-marker') + + await expect(request('/account/profile', {}, { isPublicAPI: true })).rejects.toBe(response) + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('console-marker') }) }) diff --git a/web/service/base.ts b/web/service/base.ts index d5ece4cd7d4..ea2435fdb6b 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -30,6 +30,7 @@ import type { } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import Cookies from 'js-cookie' +import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state' import { API_PREFIX, CSRF_COOKIE_NAME, @@ -197,6 +198,14 @@ export type IOtherOptions = { onDataSourceNodeError?: IOnDataSourceNodeError } +const discardRegistrationStateForConsoleAuthBoundary = ({ + isMarketplaceAPI, + isPublicAPI, +}: IOtherOptions) => { + if (isMarketplaceAPI || isPublicAPI) return + discardRegistrationSessionState() +} + function jumpTo(url: string) { if (!url || !isClient) return const targetPath = new URL(url, window.location.origin).pathname @@ -1008,6 +1017,7 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe const [parseErr, errRespData] = await asyncRunSafe<ResponseError>(errResp.json()) if (parseErr) { + discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch) window.location.href = buildSigninUrlWithRedirect() return Promise.reject(err) } @@ -1025,6 +1035,7 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe } if (code === 'unauthorized_and_force_logout') { // Cookies will be cleared by the backend + discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch) window.location.reload() return Promise.reject(err) } @@ -1053,6 +1064,7 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe // there. Redirecting to /signin loses the user_code context and // the post-login flow lands on /apps instead of returning here. if (window.location.pathname === `${basePath}/device`) return Promise.reject(err) + discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch) if (window.location.pathname !== `${basePath}/signin`) { jumpTo(buildSigninUrlWithRedirect()) return Promise.reject(err) diff --git a/web/service/client.ts b/web/service/client.ts index 4aa5001b371..14ad3c465f7 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -36,6 +36,23 @@ function getMarketplaceHeaders() { }) } +// 15s deadline so a stalled Marketplace fetch can error/retry. +const MARKETPLACE_REQUEST_TIMEOUT_MS = 15_000 + +// Combine the caller's abort with the deadline; AbortSignal.any is too new. +function withRequestDeadline(callerSignal: AbortSignal | null | undefined): AbortSignal { + const deadline = AbortSignal.timeout(MARKETPLACE_REQUEST_TIMEOUT_MS) + if (!callerSignal) return deadline + if (callerSignal.aborted) return callerSignal + + const controller = new AbortController() + callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), { + once: true, + }) + deadline.addEventListener('abort', () => controller.abort(deadline.reason), { once: true }) + return controller.signal +} + function isURL(path: string) { try { // oxlint-disable-next-line no-new @@ -99,9 +116,11 @@ const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { url: MARKETPLACE_API_PREFIX, headers: () => getMarketplaceHeaders(), fetch: (request, init) => { + const requestInit = init as RequestInit | undefined return globalThis.fetch(request, { - ...init, + ...requestInit, cache: 'no-store', + signal: withRequestDeadline(requestInit?.signal ?? request.signal), }) }, interceptors: [ diff --git a/web/service/common.spec.ts b/web/service/common.spec.ts index ef678cb69f9..cfbf09ad331 100644 --- a/web/service/common.spec.ts +++ b/web/service/common.spec.ts @@ -1,5 +1,14 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { createElement } from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { + OAUTH_REGISTRATION_GA_SENT_KEY, + REGISTRATION_SUCCESS_STORAGE_KEY, +} from '@/app/components/base/amplitude/registration-session-state' import { emailLoginWithCode, sendEMailLoginCode } from './common' +import { useLogout } from './use-common' const mocks = vi.hoisted(() => ({ post: vi.fn(), @@ -68,3 +77,29 @@ describe('emailLoginWithCode', () => { }) }) }) + +describe('useLogout', () => { + beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.clear() + }) + + it('discards registration delivery state after a successful logout', async () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['account-profile'], { id: 'previous-user' }) + window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'pending-marker') + window.sessionStorage.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true') + mocks.post.mockResolvedValueOnce({ result: 'success' }) + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + const { result } = renderHook(() => useLogout(), { wrapper }) + + await act(async () => { + await result.current.mutateAsync() + }) + + expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(OAUTH_REGISTRATION_GA_SENT_KEY)).toBeNull() + expect(queryClient.getQueryData(['account-profile'])).toBeUndefined() + }) +}) diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts new file mode 100644 index 00000000000..7a29322e601 --- /dev/null +++ b/web/service/marketplace-template-discovery.spec.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' + +const mocks = vi.hoisted(() => ({ + templateCollections: vi.fn(), + templateCollectionTemplates: vi.fn(), + templateSearch: vi.fn(), +})) + +vi.mock('./client', () => ({ + marketplaceClient: { + templateCollections: (...args: unknown[]) => mocks.templateCollections(...args), + templateCollectionTemplates: (...args: unknown[]) => mocks.templateCollectionTemplates(...args), + templateSearch: (...args: unknown[]) => mocks.templateSearch(...args), + }, +})) + +// The collections helper keeps a module-level cache, so import a fresh copy +// per test to keep them isolated. +const importDiscovery = async () => { + vi.resetModules() + return import('./marketplace-template-discovery') +} + +describe('marketplace template discovery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads each template collection and isolates a failed collection', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [ + { name: 'featured', label: {}, description: {}, priority: 1 }, + { name: 'partners', label: {}, description: {}, priority: 2 }, + ], + }, + }) + mocks.templateCollectionTemplates + .mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } }) + .mockRejectedValueOnce(new Error('Unavailable')) + + const result = await getMarketplaceTemplateCollectionsAndTemplates() + + expect(mocks.templateCollections).toHaveBeenCalledWith({ + query: { page: 1, page_size: 100 }, + }) + expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, { + params: { collectionName: 'featured' }, + body: { limit: 24 }, + }) + expect(result.templatesByCollection).toEqual({ + featured: [{ id: 'template-1' }], + partners: [], + }) + }) + + it('serves collections from the cache instead of refetching every render', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }], + }, + }) + mocks.templateCollectionTemplates.mockResolvedValue({ + data: { templates: [{ id: 'template-1' }] }, + }) + + const [first, second] = await Promise.all([ + getMarketplaceTemplateCollectionsAndTemplates(), + getMarketplaceTemplateCollectionsAndTemplates(), + ]) + const third = await getMarketplaceTemplateCollectionsAndTemplates() + + expect(mocks.templateCollections).toHaveBeenCalledOnce() + expect(mocks.templateCollectionTemplates).toHaveBeenCalledOnce() + expect(second).toBe(first) + expect(third).toBe(first) + }) + + it('does not cache a failed collections fetch', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockRejectedValueOnce(new Error('Unavailable')) + + const failed = await getMarketplaceTemplateCollectionsAndTemplates() + expect(failed).toEqual({ collections: [], templatesByCollection: {}, ok: false }) + + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }], + }, + }) + mocks.templateCollectionTemplates.mockResolvedValue({ + data: { templates: [{ id: 'template-1' }] }, + }) + + const recovered = await getMarketplaceTemplateCollectionsAndTemplates() + expect(recovered.ok).toBe(true) + expect(recovered.templatesByCollection).toEqual({ featured: [{ id: 'template-1' }] }) + }) + + it('sends category searches through the Marketplace contract', async () => { + const { searchMarketplaceTemplates } = await importDiscovery() + mocks.templateSearch.mockResolvedValue({ + data: { + templates: [{ id: 'template-1' }], + total: 1, + }, + }) + + const result = await searchMarketplaceTemplates({ + category: 'marketing', + page: 2, + query: 'campaign', + }) + + expect(mocks.templateSearch).toHaveBeenCalledWith({ + body: { + page: 2, + page_size: 40, + query: 'campaign', + sort_by: 'usage_count', + sort_order: 'DESC', + categories: ['marketing'], + }, + }) + expect(result).toEqual({ ok: true, page: 2, templates: [{ id: 'template-1' }], total: 1 }) + }) + + it('marks a failed template search instead of reporting an empty result', async () => { + const { searchMarketplaceTemplates } = await importDiscovery() + mocks.templateSearch.mockRejectedValueOnce(new Error('Unavailable')) + + const result = await searchMarketplaceTemplates({ + category: 'all', + query: 'campaign', + }) + + expect(result).toEqual({ ok: false, page: 1, templates: [], total: 0 }) + }) +}) diff --git a/web/service/marketplace-template-discovery.ts b/web/service/marketplace-template-discovery.ts new file mode 100644 index 00000000000..9cbb4f5fcf7 --- /dev/null +++ b/web/service/marketplace-template-discovery.ts @@ -0,0 +1,148 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { marketplaceClient } from './client' + +export type MarketplaceTemplateCollectionsResult = { + collections: MarketplaceTemplateCollection[] + templatesByCollection: Record<string, MarketplaceTemplate[]> + /** + * False when the Marketplace API request failed, so the UI can render an + * error state instead of claiming the catalog is empty. + */ + ok: boolean +} + +export const TEMPLATE_SEARCH_PAGE_SIZE = 40 + +type SearchMarketplaceTemplatesOptions = { + category: string + languages?: string[] + page?: number + query: string + sortBy?: string + sortOrder?: string +} + +const FAILED_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = { + collections: [], + templatesByCollection: {}, + ok: false, +} + +const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 24 +const COLLECTION_FETCH_BATCH_SIZE = 5 +const COLLECTIONS_CACHE_TTL_MS = 5 * 60 * 1000 + +let collectionsCache: { + expiresAt: number + result: MarketplaceTemplateCollectionsResult +} | null = null +let collectionsInFlight: Promise<MarketplaceTemplateCollectionsResult> | null = null + +async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> { + const response = await marketplaceClient.templateCollections({ + query: { + page: 1, + page_size: 100, + }, + }) + const collections = response.data?.collections ?? [] + const entries: (readonly [string, MarketplaceTemplate[]])[] = [] + + // Bounded fan-out: fetch collection previews in small batches instead of + // firing one uncached request per collection all at once. + for ( + let batchStart = 0; + batchStart < collections.length; + batchStart += COLLECTION_FETCH_BATCH_SIZE + ) { + const batch = collections.slice(batchStart, batchStart + COLLECTION_FETCH_BATCH_SIZE) + entries.push( + ...(await Promise.all( + batch.map(async (collection) => { + try { + const collectionResponse = await marketplaceClient.templateCollectionTemplates({ + params: { collectionName: collection.name }, + body: { limit: COLLECTION_PREVIEW_TEMPLATE_LIMIT }, + }) + + return [collection.name, collectionResponse.data?.templates ?? []] as const + } catch { + return [collection.name, [] as MarketplaceTemplate[]] as const + } + }), + )), + ) + } + + return { + collections, + templatesByCollection: Object.fromEntries(entries), + ok: true, + } +} + +/** + * Server-side cached view of the template collections and their previews. + * `marketplaceClient` opts out of the framework fetch cache (`no-store`), so + * without this cache every server render of /templates would fan out to up to + * 1 + N external requests. Successful results are reused for a few minutes and + * concurrent renders share a single in-flight fetch; failures are not cached. + */ +export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> { + if (collectionsCache && collectionsCache.expiresAt > Date.now()) return collectionsCache.result + if (collectionsInFlight) return collectionsInFlight + + collectionsInFlight = fetchCollectionsAndTemplates() + .then((result) => { + collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result } + return result + }) + .catch(() => FAILED_COLLECTIONS_RESULT) + .finally(() => { + collectionsInFlight = null + }) + + return collectionsInFlight +} + +export async function searchMarketplaceTemplates({ + category, + languages, + page = 1, + query, + sortBy = 'usage_count', + sortOrder = 'DESC', +}: SearchMarketplaceTemplatesOptions) { + try { + const response = await marketplaceClient.templateSearch({ + body: { + page, + page_size: TEMPLATE_SEARCH_PAGE_SIZE, + query, + sort_by: sortBy, + sort_order: sortOrder, + ...(category === 'all' ? {} : { categories: [category] }), + ...(languages?.length ? { languages } : {}), + }, + }) + + return { + ok: true, + page, + templates: response.data?.templates ?? [], + total: response.data?.total ?? 0, + } + } catch { + // Marked as failed so callers can distinguish an API outage from a + // genuinely empty search result. + return { + ok: false, + page, + templates: [], + total: 0, + } + } +} diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 95564c5fbea..524a38bfd0f 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -17,6 +17,7 @@ import type { } from '@/models/common' import type { RETRIEVE_METHOD } from '@/types/app' import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state' // oxlint-disable-next-line no-restricted-imports import { get, post } from './base' import { consoleQuery } from './client' @@ -162,6 +163,7 @@ export const useLogout = () => { mutationKey: [NAME_SPACE, 'logout'], mutationFn: () => post('/logout'), onSuccess: () => { + discardRegistrationSessionState() // Drop all cached queries so the post-logout /signin probe doesn't read // the previous user's profile (the userProfile queryKey is shared with // the (commonLayout) tree, which keeps observing it during React's diff --git a/web/types/assets.d.ts b/web/types/assets.d.ts index 6afed58b48d..fbdbcc6e762 100644 --- a/web/types/assets.d.ts +++ b/web/types/assets.d.ts @@ -24,3 +24,8 @@ declare module '*.gif' { const value: any export default value } + +declare module '*.webp' { + const value: any + export default value +} diff --git a/web/utils/__tests__/marketplace-site-track.spec.ts b/web/utils/__tests__/marketplace-site-track.spec.ts new file mode 100644 index 00000000000..3ed02ccebff --- /dev/null +++ b/web/utils/__tests__/marketplace-site-track.spec.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + markMarketplaceSiteFilter, + markMarketplaceSiteSearch, + trackMarketplaceSiteCardClick, + trackMarketplaceSiteEvent, +} from '../marketplace-site-track' + +describe('marketplace site track bridge', () => { + afterEach(() => { + document.body.removeAttribute('data-is-marketplace') + delete window.__marketplaceTracking__ + }) + + it('does not forward events outside the standalone marketplace', () => { + const track = vi.fn() + window.__marketplaceTracking__ = { track } as never + + trackMarketplaceSiteEvent('marketplace_card_click', { click_target: 'card' }) + + expect(track).not.toHaveBeenCalled() + }) + + it('forwards events and card clicks on the standalone marketplace', () => { + const track = vi.fn() + const rememberReferrer = vi.fn() + document.body.setAttribute('data-is-marketplace', '') + window.__marketplaceTracking__ = { + track, + rememberReferrer, + markSearch: vi.fn(), + flushSearch: vi.fn(), + markFilter: vi.fn(), + flushFilter: vi.fn(), + } + + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'creator_center', + }) + trackMarketplaceSiteCardClick({ + itemId: 'org/name', + itemType: 'plugin', + section: 'partners', + }) + markMarketplaceSiteSearch('openai') + markMarketplaceSiteFilter({ + filter_type: 'type_tab', + selection_mode: 'single', + filter_value: 'tool', + selected_values: ['tool'], + }) + + expect(track).toHaveBeenNthCalledWith(1, 'marketplace_creator_partner_click', { + click_target: 'creator_center', + }) + expect(rememberReferrer).toHaveBeenCalledWith('org/name', 'list') + expect(track).toHaveBeenNthCalledWith(2, 'marketplace_card_click', { + click_target: 'card', + item_id: 'org/name', + item_type: 'plugin', + section: 'partners', + }) + }) +}) diff --git a/web/utils/marketplace-site-track.ts b/web/utils/marketplace-site-track.ts new file mode 100644 index 00000000000..f5bbdfe8e0a --- /dev/null +++ b/web/utils/marketplace-site-track.ts @@ -0,0 +1,74 @@ +type MarketplaceSiteReferrerSection = 'banner' | 'search' | 'list' | 'direct' + +type MarketplaceSiteFilter = { + filter_type: 'type_tab' | 'category' | 'language' + selection_mode: 'single' | 'multi' + filter_value: string + selected_values: string[] +} + +const isMarketplaceSite = () => + typeof globalThis.document !== 'undefined' && + globalThis.document.body?.hasAttribute('data-is-marketplace') + +const marketplaceTracking = () => globalThis.window.__marketplaceTracking__ + +export const trackMarketplaceSiteEvent = ( + eventName: string, + properties?: Record<string, unknown>, +) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.track(eventName, properties) +} + +export const rememberMarketplaceSiteReferrer = ( + itemId: string, + section: MarketplaceSiteReferrerSection, +) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.rememberReferrer(itemId, section) +} + +export const markMarketplaceSiteSearch = (query: string) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.markSearch(query) +} + +export const flushMarketplaceSiteSearch = (resultCount: number) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.flushSearch(resultCount) +} + +export const markMarketplaceSiteFilter = (filter: MarketplaceSiteFilter) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.markFilter(filter) +} + +export const flushMarketplaceSiteFilter = (resultCount: number) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.flushFilter(resultCount) +} + +export const trackMarketplaceSiteCardClick = ({ + itemId, + itemType, + section, +}: { + itemId: string + itemType: 'plugin' | 'template' + section: string +}) => { + rememberMarketplaceSiteReferrer(itemId, section === 'search' ? 'search' : 'list') + trackMarketplaceSiteEvent('marketplace_card_click', { + click_target: 'card', + item_id: itemId, + item_type: itemType, + section, + }) +} diff --git a/web/utils/var.spec.ts b/web/utils/var.spec.ts index c871eea6762..67bc8334cde 100644 --- a/web/utils/var.spec.ts +++ b/web/utils/var.spec.ts @@ -219,6 +219,18 @@ describe('Variable Utilities', () => { expect(url).not.toContain('source=https%253A%252F%252Fexample.com') }) + it('should let params replace the default source without duplicating it', () => { + const url = getMarketplaceUrl( + '/plugins', + { source: 'http://localhost:3001', language: 'en-US' }, + { source: 'http://localhost:3000' }, + ) + const searchParams = new URL(url, 'https://marketplace.dify.ai').searchParams + + expect(searchParams.getAll('source')).toEqual(['http://localhost:3001']) + expect(searchParams.get('language')).toBe('en-US') + }) + it('should not access window during server render', () => { const originalWindow = window vi.stubGlobal('window', undefined) diff --git a/web/utils/var.ts b/web/utils/var.ts index 0a6a1a586b1..acccd7211ce 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -171,7 +171,7 @@ export function getMarketplaceUrl( if (params) { Object.keys(params).forEach((key) => { const value = params[key] - if (value !== undefined && value !== null) searchParams.append(key, value) + if (value !== undefined && value !== null) searchParams.set(key, value) }) }