From 2d15743b96e9fbb7e2d06b835f1c0c2987300470 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:11:52 +0900 Subject: [PATCH 01/63] test: use SQLite sessions in services misc (#39114) --- api/tests/unit_tests/services/hit_service.py | 98 +++++++++---------- ...kflow_event_snapshot_service_additional.py | 98 ++++++++++++------- 2 files changed, 108 insertions(+), 88 deletions(-) diff --git a/api/tests/unit_tests/services/hit_service.py b/api/tests/unit_tests/services/hit_service.py index 0257fd43676..2a456dc4b9d 100644 --- a/api/tests/unit_tests/services/hit_service.py +++ b/api/tests/unit_tests/services/hit_service.py @@ -6,17 +6,25 @@ which handles retrieval testing operations for datasets, including internal dataset retrieval and external knowledge base retrieval. """ +import json from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session from core.rag.models.document import Document from core.rag.retrieval.retrieval_methods import RetrievalMethod from models import Account -from models.dataset import Dataset +from models.dataset import Dataset, DatasetQuery from services.hit_testing_service import HitTestingService +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [(DatasetQuery,)], indirect=True), +] + class HitTestingTestDataFactory: """ @@ -139,17 +147,7 @@ class TestHitTestingServiceRetrieve: various retrieval model configurations, metadata filtering, and query logging. """ - @pytest.fixture - def mock_db_session(self): - """ - Mock database session. - - Provides a mocked database session for testing database operations - like adding and committing DatasetQuery records. - """ - return MagicMock() - - def test_retrieve_success_with_default_retrieval_model(self, mock_db_session): + def test_retrieve_success_with_default_retrieval_model(self, sqlite_session: Session): """ Test successful retrieval with default retrieval model. @@ -186,17 +184,20 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert assert result["query"]["content"] == query assert len(result["records"]) == 2 mock_retrieve.assert_called_once() - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() + query_log = sqlite_session.scalar(select(DatasetQuery)) + assert query_log is not None + assert query_log.dataset_id == dataset.id + assert query_log.created_by == account.id + assert json.loads(query_log.content) == [{"content_type": "text_query", "content": query}] - def test_retrieve_success_with_custom_retrieval_model(self, mock_db_session): + def test_retrieve_success_with_custom_retrieval_model(self, sqlite_session: Session): """ Test successful retrieval with custom retrieval model. @@ -234,7 +235,7 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert @@ -246,7 +247,7 @@ class TestHitTestingServiceRetrieve: assert call_kwargs["score_threshold"] == 0.7 assert call_kwargs["reranking_model"] == retrieval_model["reranking_model"] - def test_retrieve_with_metadata_filtering(self, mock_db_session): + def test_retrieve_with_metadata_filtering(self, sqlite_session: Session): """ Test retrieval with metadata filtering conditions. @@ -292,7 +293,7 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert @@ -301,7 +302,7 @@ class TestHitTestingServiceRetrieve: call_kwargs = mock_retrieve.call_args[1] assert call_kwargs["document_ids_filter"] == ["doc-1", "doc-2"] - def test_retrieve_with_metadata_filtering_no_documents(self, mock_db_session): + def test_retrieve_with_metadata_filtering_no_documents(self, sqlite_session: Session): """ Test retrieval with metadata filtering that returns no documents. @@ -337,14 +338,14 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert assert result["query"]["content"] == query assert result["records"] == [] - def test_retrieve_with_dataset_retrieval_model(self, mock_db_session): + def test_retrieve_with_dataset_retrieval_model(self, sqlite_session: Session): """ Test retrieval using dataset's retrieval model when not provided. @@ -380,7 +381,7 @@ class TestHitTestingServiceRetrieve: # Act result = HitTestingService.retrieve( - dataset, query, account, retrieval_model, external_retrieval_model, session=mock_db_session + dataset, query, account, retrieval_model, external_retrieval_model, session=sqlite_session ) # Assert @@ -398,17 +399,7 @@ class TestHitTestingServiceExternalRetrieve: including query escaping, response formatting, and provider validation. """ - @pytest.fixture - def mock_db_session(self): - """ - Mock database session. - - Provides a mocked database session for testing database operations - like adding and committing DatasetQuery records. - """ - return MagicMock() - - def test_external_retrieve_success(self, mock_db_session): + def test_external_retrieve_success(self, sqlite_session: Session): """ Test successful external retrieval. @@ -443,7 +434,7 @@ class TestHitTestingServiceExternalRetrieve: account, external_retrieval_model, metadata_filtering_conditions, - session=mock_db_session, + session=sqlite_session, ) # Assert @@ -455,10 +446,13 @@ class TestHitTestingServiceExternalRetrieve: mock_external_retrieve.assert_called_once() # Verify query was escaped assert mock_external_retrieve.call_args[1]["query"] == 'test query with \\"quotes\\"' - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() + query_log = sqlite_session.scalar(select(DatasetQuery)) + assert query_log is not None + assert query_log.dataset_id == dataset.id + assert query_log.content == query + assert query_log.created_by == account.id - def test_external_retrieve_non_external_provider(self, mock_db_session): + def test_external_retrieve_non_external_provider(self, sqlite_session: Session): """ Test external retrieval with non-external provider (should return empty). @@ -474,15 +468,15 @@ class TestHitTestingServiceExternalRetrieve: # Act result = HitTestingService.external_retrieve( - dataset, query, account, external_retrieval_model, metadata_filtering_conditions, session=mock_db_session + dataset, query, account, external_retrieval_model, metadata_filtering_conditions, session=sqlite_session ) # Assert assert result["query"]["content"] == query assert result["records"] == [] - mock_db_session.add.assert_not_called() + assert sqlite_session.scalar(select(DatasetQuery)) is None - def test_external_retrieve_with_metadata_filtering(self, mock_db_session): + def test_external_retrieve_with_metadata_filtering(self, sqlite_session: Session): """ Test external retrieval with metadata filtering conditions. @@ -514,7 +508,7 @@ class TestHitTestingServiceExternalRetrieve: account, external_retrieval_model, metadata_filtering_conditions, - session=mock_db_session, + session=sqlite_session, ) # Assert @@ -523,7 +517,7 @@ class TestHitTestingServiceExternalRetrieve: call_kwargs = mock_external_retrieve.call_args[1] assert call_kwargs["metadata_filtering_conditions"] == metadata_filtering_conditions - def test_external_retrieve_empty_documents(self, mock_db_session): + def test_external_retrieve_empty_documents(self, sqlite_session: Session): """ Test external retrieval with empty document list. @@ -553,7 +547,7 @@ class TestHitTestingServiceExternalRetrieve: account, external_retrieval_model, metadata_filtering_conditions, - session=mock_db_session, + session=sqlite_session, ) # Assert @@ -569,7 +563,7 @@ class TestHitTestingServiceCompactRetrieveResponse: ensuring documents are properly formatted into retrieval records. """ - def test_compact_retrieve_response_success(self): + def test_compact_retrieve_response_success(self, sqlite_session: Session): """ Test successful response formatting. @@ -587,7 +581,6 @@ class TestHitTestingServiceCompactRetrieveResponse: HitTestingTestDataFactory.create_retrieval_record_mock(content="Doc 1", score=0.95), HitTestingTestDataFactory.create_retrieval_record_mock(content="Doc 2", score=0.85), ] - session = MagicMock() with patch( "services.hit_testing_service.RetrievalService.format_retrieval_documents", autospec=True @@ -595,7 +588,7 @@ class TestHitTestingServiceCompactRetrieveResponse: mock_format.return_value = mock_records # Act - result = HitTestingService.compact_retrieve_response(query, documents, session=session) + result = HitTestingService.compact_retrieve_response(query, documents, session=sqlite_session) # Assert assert result["query"]["content"] == query @@ -603,10 +596,11 @@ class TestHitTestingServiceCompactRetrieveResponse: assert result["records"][0]["content"] == "Doc 1" assert result["records"][0]["score"] == 0.95 mock_format.assert_called_once() - assert mock_format.call_args.args[0] is not session + assert mock_format.call_args.args[0] is not sqlite_session + assert mock_format.call_args.args[0].get_bind() is sqlite_session.get_bind() assert mock_format.call_args.args[1] == documents - def test_compact_retrieve_response_empty_documents(self): + def test_compact_retrieve_response_empty_documents(self, sqlite_session: Session): """ Test response formatting with empty document list. @@ -616,7 +610,6 @@ class TestHitTestingServiceCompactRetrieveResponse: # Arrange query = "test query" documents = [] - session = MagicMock() with patch( "services.hit_testing_service.RetrievalService.format_retrieval_documents", autospec=True @@ -624,13 +617,14 @@ class TestHitTestingServiceCompactRetrieveResponse: mock_format.return_value = [] # Act - result = HitTestingService.compact_retrieve_response(query, documents, session=session) + result = HitTestingService.compact_retrieve_response(query, documents, session=sqlite_session) # Assert assert result["query"]["content"] == query assert result["records"] == [] mock_format.assert_called_once() - assert mock_format.call_args.args[0] is not session + assert mock_format.call_args.args[0] is not sqlite_session + assert mock_format.call_args.args[0].get_bind() is sqlite_session.get_bind() assert mock_format.call_args.args[1] == documents diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py index be6f9ff1fc0..8efd7370a73 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py @@ -10,6 +10,8 @@ from typing import Any, cast from unittest.mock import MagicMock import pytest +from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig @@ -21,8 +23,9 @@ from core.app.layers.pause_state_persist_layer import ( ) from graphon.enums import WorkflowExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool -from models.enums import CreatorUserRole -from models.model import AppMode +from models.base import TypeBase +from models.enums import CreatorUserRole, MessageStatus +from models.model import AppMode, Message from models.workflow import WorkflowRun from repositories.entities.workflow_pause import WorkflowPauseEntity from services import workflow_event_snapshot_service as service_module @@ -79,23 +82,47 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: ) -class _SessionContext: - def __init__(self, session: Any) -> None: - self._session = session - - def __enter__(self) -> Any: - return self._session - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False +@pytest.fixture +def message_session_maker(sqlite_engine: Engine) -> sessionmaker[Session]: + """Create real sessions containing only workflow messages.""" + TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[Message.__tablename__]]) + return sessionmaker(bind=sqlite_engine, expire_on_commit=False) -class _SessionMaker: - def __init__(self, session: Any) -> None: - self._session = session - - def __call__(self) -> _SessionContext: - return _SessionContext(self._session) +def _persist_message(session_maker: sessionmaker[Session]) -> Message: + message = Message( + app_id="app-1", + model_provider="provider", + model_id="model", + override_model_configs=None, + conversation_id="conv-1", + inputs={}, + query="hello", + message="", + message_tokens=0, + message_unit_price=0, + message_price_unit=0, + answer="answer", + answer_tokens=0, + answer_unit_price=0, + answer_price_unit=0, + parent_message_id=None, + provider_response_latency=0, + total_price=0, + currency="USD", + invoke_from=InvokeFrom.WEB_APP, + from_source="api", + from_end_user_id="user-1", + from_account_id=None, + app_mode=AppMode.WORKFLOW, + status=MessageStatus.NORMAL, + workflow_run_id="run-1", + ) + message.id = "msg-1" + with session_maker() as session: + session.add(message) + session.commit() + return message class _SubscriptionContext: @@ -150,12 +177,11 @@ class _PauseEntity(WorkflowPauseEntity): class TestWorkflowEventSnapshotHelpers: - def test_get_message_context_by_conversation_should_return_none_when_no_message(self) -> None: - session = SimpleNamespace(scalar=MagicMock(return_value=None)) - session_maker = _SessionMaker(session) - + def test_get_message_context_by_conversation_should_return_none_when_no_message( + self, message_session_maker: sessionmaker[Session] + ) -> None: result = service_module._get_message_context_by_conversation( - cast(sessionmaker[Session], session_maker), + message_session_maker, conversation_id="conv-1", workflow_run_id="run-1", ) @@ -163,22 +189,22 @@ class TestWorkflowEventSnapshotHelpers: assert result is None def test_get_message_context_by_conversation_should_default_created_at_to_zero_when_message_has_no_timestamp( - self, + self, message_session_maker: sessionmaker[Session] ) -> None: - message = SimpleNamespace( - id="msg-1", - conversation_id="conv-1", - created_at=None, - answer="answer", - ) - session = SimpleNamespace(scalar=MagicMock(return_value=message)) - session_maker = _SessionMaker(session) + _persist_message(message_session_maker) - result = service_module._get_message_context_by_conversation( - cast(sessionmaker[Session], session_maker), - conversation_id="conv-1", - workflow_run_id="run-1", - ) + def clear_created_at(message: Message, _context: Any) -> None: + message.created_at = None # type: ignore[assignment] + + event.listen(Message, "load", clear_created_at) + try: + result = service_module._get_message_context_by_conversation( + message_session_maker, + conversation_id="conv-1", + workflow_run_id="run-1", + ) + finally: + event.remove(Message, "load", clear_created_at) assert result is not None assert result.created_at == 0 From cb2b36f1aac5a1c6b44d9b00cd4cd073366e64f8 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:13:52 +0900 Subject: [PATCH 02/63] test: use SQLite sessions in services enterprise (#39113) --- .../services/enterprise/test_rbac_service.py | 163 +++++++++++------- 1 file changed, 98 insertions(+), 65 deletions(-) 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 4c8b779491c..27f240797c1 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -1,11 +1,9 @@ """Unit tests for services.enterprise.rbac_service. -The enterprise RBAC client is almost pure glue: each method turns a single -``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response -model. Rather than spinning up an HTTP server we monkeypatch that helper and -assert on the arguments it received; that catches both routing regressions -(wrong method / wrong path / wrong params) and model-shape regressions in -one place. +Most enterprise RBAC methods turn a single ``EnterpriseRequest.send_inner_rbac_request`` +call into a pydantic response model. Rather than spinning up an HTTP server, these tests +monkeypatch that helper and assert on the request arguments and response shape. The legacy +fallbacks use SQLite to verify their database reads and committed role updates. """ from __future__ import annotations @@ -15,7 +13,10 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from sqlalchemy import select +from sqlalchemy.orm import Session +from models import TenantAccountJoin from services.enterprise import rbac_service as svc MODULE = "services.enterprise.rbac_service" @@ -533,8 +534,9 @@ class TestWorkspaceAccess: assert call.params == {"language": "en"} +@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestMyPermissions: - def test_resource_snapshot_maps_defaults_and_overrides(self): + def test_resource_snapshot_maps_defaults_and_overrides(self, sqlite_session: Session): snapshot = svc.ResourcePermissionSnapshot( default_permission_keys=["app.acl.view_layout"], overrides=[ @@ -550,7 +552,7 @@ class TestMyPermissions: "app-2": ["app.acl.view_layout", "app.acl.edit"], } - def test_get_without_payload_uses_get(self, mock_send: MagicMock): + def test_get_without_payload_uses_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "workspace": {"permission_keys": ["workspace.member.manage"]}, "app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []}, @@ -558,7 +560,7 @@ class TestMyPermissions: } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=MagicMock()) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" @@ -609,12 +611,14 @@ class TestMyPermissions: workspace_keys: list[str], app_keys: list[str], dataset_keys: list[str], + sqlite_session: Session, ): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = role + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole(role)) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_session) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) mock_send.assert_not_called() assert out.workspace.permission_keys == workspace_keys @@ -648,12 +652,14 @@ class TestMyPermissions: mock_send: MagicMock, role: str, expected_snippet_keys: set[str], + sqlite_session: Session, ): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = role + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole(role)) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_session) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) actual_snippet_keys = { permission_key for permission_key in out.workspace.permission_keys if permission_key.startswith("snippets.") @@ -662,19 +668,16 @@ class TestMyPermissions: mock_send.assert_not_called() assert actual_snippet_keys == expected_snippet_keys - def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = None + def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock, sqlite_session: Session): with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=mock_session) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", session=sqlite_session) mock_send.assert_not_called() assert out.workspace.permission_keys == [] assert out.app.default_permission_keys == [] assert out.dataset.default_permission_keys == [] - def test_get_with_single_resource_filters(self, mock_send: MagicMock): + def test_get_with_single_resource_filters(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "workspace": {"permission_keys": []}, "app": { @@ -685,7 +688,7 @@ class TestMyPermissions: } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): - out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1", session=MagicMock()) + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" @@ -694,8 +697,9 @@ class TestMyPermissions: assert out.app.overrides[0].resource_id == "app-1" +@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestMemberRoles: - def test_get(self, mock_send: MagicMock): + def test_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "account_id": "acct-2", "roles": [ @@ -707,7 +711,7 @@ class TestMemberRoles: ], } with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): - out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=MagicMock()) + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=sqlite_session) call = _call_args(mock_send) assert call.method == "GET" assert call.endpoint == "/rbac/members/rbac-roles" @@ -715,12 +719,14 @@ class TestMemberRoles: assert out.account_id == "acct-2" assert out.roles[0].name == "Member" - def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock): - session = MagicMock() - session.scalar.return_value = svc.TenantAccountRole.EDITOR + def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock, sqlite_session: Session): + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.EDITOR) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): - out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=session) + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2", session=sqlite_session) mock_send.assert_not_called() assert out.account_id == "acct-2" @@ -738,7 +744,7 @@ class TestMemberRoles: assert "app.acl.preview" in out.roles[0].permission_keys assert "dataset.acl.preview" in out.roles[0].permission_keys - def test_replace(self, mock_send: MagicMock): + def test_replace(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = {"account_id": "acct-2", "roles": []} with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): svc.RBACService.MemberRoles.replace( @@ -746,7 +752,7 @@ class TestMemberRoles: "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"], - session=MagicMock(), + session=sqlite_session, ) call = _call_args(mock_send) assert call.method == "PUT" @@ -754,43 +760,59 @@ class TestMemberRoles: assert call.params == {"account_id": "acct-2"} assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]} - def test_replace_updates_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock): - session = MagicMock() - session.__enter__.return_value = session - target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2") - session.scalar.return_value = target_join + def test_replace_commits_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock, sqlite_session: Session): + target_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.NORMAL) + sqlite_session.add(target_join) + sqlite_session.commit() + target_join_id = target_join.id + engine = sqlite_session.get_bind() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.replace( - "tenant-1", "acct-1", "acct-2", role_ids=["editor"], session=session + "tenant-1", "acct-1", "acct-2", role_ids=["editor"], session=sqlite_session ) mock_send.assert_not_called() - session.commit.assert_called_once() - assert target_join.role == svc.TenantAccountRole.EDITOR + # Closing the writer rolls back any uncommitted update and prevents its identity map + # from satisfying the verification query. + sqlite_session.close() + with Session(engine) as verification_session: + persisted_join = verification_session.scalar( + select(TenantAccountJoin).where(TenantAccountJoin.id == target_join_id) + ) + assert persisted_join is not None + assert persisted_join.role == svc.TenantAccountRole.EDITOR assert out.account_id == "acct-2" assert out.roles[0].id == "editor" assert "app.acl.preview" in out.roles[0].permission_keys - def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled(self, mock_send: MagicMock): - session = MagicMock() - session.__enter__.return_value = session - target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2") - owner_join = SimpleNamespace(role=svc.TenantAccountRole.OWNER, account_id="acct-owner") - session.scalar.side_effect = [target_join, owner_join] + def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled( + self, mock_send: MagicMock, sqlite_session: Session + ): + target_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-2", role=svc.TenantAccountRole.NORMAL) + owner_join = TenantAccountJoin(tenant_id="tenant-1", account_id="acct-owner", role=svc.TenantAccountRole.OWNER) + sqlite_session.add_all([target_join, owner_join]) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.MemberRoles.replace( - "tenant-1", "acct-1", "acct-2", role_ids=["owner"], session=session + "tenant-1", "acct-1", "acct-2", role_ids=["owner"], session=sqlite_session ) mock_send.assert_not_called() - session.commit.assert_called_once() - assert target_join.role == svc.TenantAccountRole.OWNER - assert owner_join.role == svc.TenantAccountRole.ADMIN + persisted_joins = { + join.account_id: join.role + for join in sqlite_session.scalars( + select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == "tenant-1") + ) + } + assert persisted_joins == { + "acct-2": svc.TenantAccountRole.OWNER, + "acct-owner": svc.TenantAccountRole.ADMIN, + } assert out.roles[0].id == "owner" - def test_batch_get(self, mock_send: MagicMock): + def test_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "acct-2": [ {"id": "role-1", "name": "Admin", "type": "workspace"}, @@ -811,8 +833,9 @@ class TestMemberRoles: assert out[1].roles == [] +@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True) class TestResourcePermissions: - def test_app_permissions_batch_get(self, mock_send: MagicMock): + def test_app_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "data": [ {"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]}, @@ -822,7 +845,7 @@ class TestResourcePermissions: with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.AppPermissions.batch_get( - "tenant-1", "acct-1", ["app-1", "app-2"], session=MagicMock() + "tenant-1", "acct-1", ["app-1", "app-2"], session=sqlite_session ) call = _call_args(mock_send) @@ -834,13 +857,16 @@ class TestResourcePermissions: "app-2": [], } - def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = "editor" + def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled( + self, mock_send: MagicMock, sqlite_session: Session + ): + sqlite_session.add( + TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole.EDITOR) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.AppPermissions.batch_get( - "tenant-1", "acct-1", ["app-1", "app-2"], session=mock_session + "tenant-1", "acct-1", ["app-1", "app-2"], session=sqlite_session ) mock_send.assert_not_called() @@ -849,7 +875,7 @@ class TestResourcePermissions: "app-2": svc._LEGACY_APP_EDITOR_KEYS, } - def test_dataset_permissions_batch_get(self, mock_send: MagicMock): + def test_dataset_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session): mock_send.return_value = { "data": [ {"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]}, @@ -859,7 +885,7 @@ class TestResourcePermissions: with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): out = svc.RBACService.DatasetPermissions.batch_get( - "tenant-1", "acct-1", ["ds-1", "ds-2"], session=MagicMock() + "tenant-1", "acct-1", ["ds-1", "ds-2"], session=sqlite_session ) call = _call_args(mock_send) @@ -871,13 +897,20 @@ class TestResourcePermissions: "ds-2": ["dataset.acl.edit"], } - def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - mock_session.scalar.return_value = "dataset_operator" + def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled( + self, mock_send: MagicMock, sqlite_session: Session + ): + sqlite_session.add( + TenantAccountJoin( + tenant_id="tenant-1", + account_id="acct-1", + role=svc.TenantAccountRole.DATASET_OPERATOR, + ) + ) + sqlite_session.commit() with patch(f"{MODULE}.dify_config.RBAC_ENABLED", False): out = svc.RBACService.DatasetPermissions.batch_get( - "tenant-1", "acct-1", ["ds-1", "ds-2"], session=mock_session + "tenant-1", "acct-1", ["ds-1", "ds-2"], session=sqlite_session ) mock_send.assert_not_called() From b38caf3cdb6ff383e236e196fb8b3face2e0c9de Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:15:01 +0900 Subject: [PATCH 03/63] test: use SQLite sessions in services core (#39111) --- .../services/test_agent_tool_inner_service.py | 100 ++++++++++++------ .../services/test_workflow_service.py | 70 ++++++------ 2 files changed, 104 insertions(+), 66 deletions(-) diff --git a/api/tests/unit_tests/services/test_agent_tool_inner_service.py b/api/tests/unit_tests/services/test_agent_tool_inner_service.py index 61049d29e9e..8f222b33093 100644 --- a/api/tests/unit_tests/services/test_agent_tool_inner_service.py +++ b/api/tests/unit_tests/services/test_agent_tool_inner_service.py @@ -1,9 +1,10 @@ -"""Unit tests for the Agent tool inner invoke service.""" +"""Unit tests for the Agent tool inner invoke service with SQLite-backed app lookup.""" from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from sqlalchemy.orm import Session from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType from core.tools.errors import ( @@ -12,19 +13,44 @@ from core.tools.errors import ( ToolProviderCredentialValidationError, ToolProviderNotFoundError, ) +from models.enums import AppStatus +from models.model import App, AppMode from services.agent_tool_inner_service import AgentToolInnerService from services.entities.agent_tool_inner import AgentToolInvokeRequest from services.errors.agent_tool_inner import AgentToolInnerServiceError +TENANT_ID = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" +USER_ID = "33333333-3333-3333-3333-333333333333" +APP_ID = "44444444-4444-4444-4444-444444444444" + + +def _persist_app(sqlite_session: Session, *, tenant_id: str = TENANT_ID) -> App: + app = App( + id=APP_ID, + tenant_id=tenant_id, + name="Test App", + description="", + mode=AppMode.CHAT, + status=AppStatus.NORMAL, + enable_site=False, + enable_api=False, + max_active_requests=None, + ) + sqlite_session.add(app) + sqlite_session.commit() + sqlite_session.expunge_all() + return app + def _request() -> AgentToolInvokeRequest: return AgentToolInvokeRequest.model_validate( { "caller": { - "tenant_id": "tenant-1", - "user_id": "user-1", + "tenant_id": TENANT_ID, + "user_id": USER_ID, "user_from": "account", - "app_id": "app-1", + "app_id": APP_ID, "invoke_from": "service-api", "conversation_id": "conversation-1", "workflow_id": "workflow-1", @@ -53,11 +79,10 @@ def _messages() -> Generator[ToolInvokeMessage, None, None]: ) -def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_uses_agent_tool_runtime_and_returns_observation(sqlite_session: Session) -> None: fake_tool = MagicMock() - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app + _persist_app(sqlite_session) with ( patch( @@ -70,7 +95,7 @@ def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: side_effect=lambda messages, **_kwargs: messages, ), ): - response = AgentToolInnerService().invoke(_request(), session=session) + response = AgentToolInnerService().invoke(_request(), session=sqlite_session) assert response.observation == "ok" assert response.metadata == { @@ -82,56 +107,58 @@ def test_invoke_uses_agent_tool_runtime_and_returns_observation() -> None: assert agent_tool.provider_type is ToolProviderType.PLUGIN assert agent_tool.tool_parameters == {"region": "us"} mock_invoke.assert_called_once() + assert mock_invoke.call_args.kwargs["session"] is sqlite_session + assert sqlite_session.in_transaction() -def test_invoke_raises_app_not_found_when_session_has_no_app() -> None: - session = MagicMock() - session.get.return_value = None - +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_raises_app_not_found_when_session_has_no_app(sqlite_session: Session) -> None: with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "app_not_found" assert exc_info.value.status_code == 404 assert exc_info.value.description == "App not found." + assert sqlite_session.in_transaction() -def test_invoke_raises_app_tenant_mismatch_when_app_belongs_to_other_tenant() -> None: - fake_app = MagicMock(id="app-1", tenant_id="tenant-2") - session = MagicMock() - session.get.return_value = fake_app +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_raises_app_tenant_mismatch_when_app_belongs_to_other_tenant(sqlite_session: Session) -> None: + _persist_app(sqlite_session, tenant_id=OTHER_TENANT_ID) with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "app_tenant_mismatch" assert exc_info.value.status_code == 403 assert exc_info.value.description == "App does not belong to the caller tenant." + assert sqlite_session.in_transaction() -def test_invoke_maps_tool_runtime_app_not_found_value_error_to_specific_error_code() -> None: +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_maps_tool_runtime_app_not_found_value_error_to_specific_error_code( + sqlite_session: Session, +) -> None: fake_tool = MagicMock() - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app + _persist_app(sqlite_session) with ( patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool), patch("services.agent_tool_inner_service.ToolEngine.generic_invoke", side_effect=ValueError("app not found")), ): with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "app_not_found" assert exc_info.value.status_code == 404 assert exc_info.value.description == "App not found." + assert sqlite_session.in_transaction() -def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> None: +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper(sqlite_session: Session) -> None: fake_tool = MagicMock() - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app + _persist_app(sqlite_session) with ( patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", return_value=fake_tool), @@ -141,9 +168,10 @@ def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> N ), ): with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == "agent_tool_invoke_failed" + assert sqlite_session.in_transaction() @pytest.mark.parametrize( @@ -154,13 +182,17 @@ def test_invoke_maps_tool_invoke_error_without_private_tool_engine_helper() -> N (ToolParameterValidationError("query is required"), "tool_parameters_invalid"), ], ) -def test_invoke_maps_runtime_lookup_errors_to_service_error_codes(error: Exception, expected_code: str) -> None: - fake_app = MagicMock(id="app-1", tenant_id="tenant-1") - session = MagicMock() - session.get.return_value = fake_app +@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) +def test_invoke_maps_runtime_lookup_errors_to_service_error_codes( + error: Exception, + expected_code: str, + sqlite_session: Session, +) -> None: + _persist_app(sqlite_session) with patch("services.agent_tool_inner_service.ToolManager.get_agent_tool_runtime", side_effect=error): with pytest.raises(AgentToolInnerServiceError) as exc_info: - AgentToolInnerService().invoke(_request(), session=session) + AgentToolInnerService().invoke(_request(), session=sqlite_session) assert exc_info.value.error_code == expected_code + assert sqlite_session.in_transaction() diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 37450cb253a..b2e0e4129c9 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -1419,6 +1419,8 @@ class TestWorkflowService: # =========================================================================== +@pytest.mark.usefixtures("sqlite_session") +@pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) class TestWorkflowServiceCredentialValidation: """ Tests for the private credential-validation helpers on WorkflowService. @@ -1444,7 +1446,7 @@ class TestWorkflowServiceCredentialValidation: # --- _validate_workflow_credentials: tool node (with credential_id) --- def test_validate_workflow_credentials_should_check_tool_credential_when_credential_id_present( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange nodes = [ @@ -1462,11 +1464,11 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert with patch("core.helper.credential_utils.check_credential_policy_compliance") as mock_check: # Should not raise; mock allows the call - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) mock_check.assert_called_once() def test_validate_workflow_credentials_should_check_default_credential_when_no_credential_id( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange nodes = [ @@ -1483,14 +1485,13 @@ class TestWorkflowServiceCredentialValidation: # Act with patch.object(service, "_check_default_tool_credential") as mock_default: - session = MagicMock() - service._validate_workflow_credentials(workflow, session=session) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert - mock_default.assert_called_once_with("tenant-1", "my-provider", session=session) + mock_default.assert_called_once_with("tenant-1", "my-provider", session=sqlite_session) def test_validate_workflow_credentials_should_skip_tool_node_without_provider( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """Tool nodes without a provider_id should be silently skipped.""" # Arrange @@ -1499,11 +1500,11 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert (no error raised) with patch.object(service, "_check_default_tool_credential") as mock_default: - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) mock_default.assert_not_called() def test_validate_workflow_credentials_should_validate_llm_node_with_model_config( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange nodes = [ @@ -1522,13 +1523,13 @@ class TestWorkflowServiceCredentialValidation: patch.object(service, "_validate_llm_model_config") as mock_llm, patch.object(service, "_validate_load_balancing_credentials"), ): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert mock_llm.assert_called_once_with("tenant-1", "openai", "gpt-4") def test_validate_workflow_credentials_should_raise_for_llm_node_missing_model( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """LLM nodes without provider AND name should raise ValueError.""" # Arrange @@ -1542,10 +1543,10 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert with pytest.raises(ValueError, match="Missing provider or model configuration"): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) def test_validate_workflow_credentials_should_wrap_unexpected_exception_in_value_error( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """Non-ValueError exceptions from validation must be re-raised as ValueError.""" # Arrange @@ -1563,9 +1564,11 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert with patch.object(service, "_validate_llm_model_config", side_effect=RuntimeError("boom")): with pytest.raises(ValueError, match="boom"): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) - def test_validate_workflow_credentials_should_validate_agent_node_model(self, service: WorkflowService) -> None: + def test_validate_workflow_credentials_should_validate_agent_node_model( + self, service: WorkflowService, sqlite_session: Session + ) -> None: # Arrange nodes = [ { @@ -1586,12 +1589,14 @@ class TestWorkflowServiceCredentialValidation: patch.object(service, "_validate_llm_model_config") as mock_llm, patch.object(service, "_validate_load_balancing_credentials"), ): - service._validate_workflow_credentials(workflow, session=MagicMock()) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert mock_llm.assert_called_once_with("tenant-1", "openai", "gpt-4") - def test_validate_workflow_credentials_should_validate_agent_tools(self, service: WorkflowService) -> None: + def test_validate_workflow_credentials_should_validate_agent_tools( + self, service: WorkflowService, sqlite_session: Session + ) -> None: """Each agent tool with a provider should be checked for credential compliance.""" # Arrange nodes = [ @@ -1618,12 +1623,11 @@ class TestWorkflowServiceCredentialValidation: patch("core.helper.credential_utils.check_credential_policy_compliance") as mock_check, patch.object(service, "_check_default_tool_credential") as mock_default, ): - session = MagicMock() - service._validate_workflow_credentials(workflow, session=session) + service._validate_workflow_credentials(workflow, session=sqlite_session) # Assert mock_check.assert_called_once() # provider-a has credential_id - mock_default.assert_called_once_with("tenant-1", "provider-b", session=session) + mock_default.assert_called_once_with("tenant-1", "provider-b", session=sqlite_session) # --- _validate_llm_model_config --- @@ -1676,14 +1680,12 @@ class TestWorkflowServiceCredentialValidation: # --- _check_default_tool_credential --- - @pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) def test_check_default_tool_credential_should_silently_pass_when_no_provider_found( self, service: WorkflowService, sqlite_session: Session ) -> None: """Missing BuiltinToolProvider → plugin requires no credentials → no error.""" service._check_default_tool_credential("tenant-1", "some-provider", session=sqlite_session) - @pytest.mark.parametrize("sqlite_session", [(BuiltinToolProvider,)], indirect=True) def test_check_default_tool_credential_should_raise_when_compliance_fails( self, service: WorkflowService, sqlite_session: Session ) -> None: @@ -1746,7 +1748,9 @@ class TestWorkflowServiceCredentialValidation: # --- _get_load_balancing_configs --- - def test_get_load_balancing_configs_should_return_empty_list_on_exception(self, service: WorkflowService) -> None: + def test_get_load_balancing_configs_should_return_empty_list_on_exception( + self, service: WorkflowService, sqlite_session: Session + ) -> None: """Any exception during LB config retrieval should return an empty list.""" # Arrange with patch( @@ -1754,12 +1758,14 @@ class TestWorkflowServiceCredentialValidation: side_effect=RuntimeError("fail"), ): # Act - result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=MagicMock()) + result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=sqlite_session) # Assert assert result == [] - def test_get_load_balancing_configs_should_merge_predefined_and_custom(self, service: WorkflowService) -> None: + def test_get_load_balancing_configs_should_merge_predefined_and_custom( + self, service: WorkflowService, sqlite_session: Session + ) -> None: # Arrange predefined = [{"credential_id": "cred-a"}, {"credential_id": None}] custom = [{"credential_id": "cred-b"}] @@ -1771,7 +1777,7 @@ class TestWorkflowServiceCredentialValidation: ], ): # Act - result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=MagicMock()) + result = service._get_load_balancing_configs("tenant-1", "openai", "gpt-4", session=sqlite_session) # Assert — only entries with a credential_id should be returned assert len(result) == 2 @@ -1780,7 +1786,7 @@ class TestWorkflowServiceCredentialValidation: # --- _validate_load_balancing_credentials --- def test_validate_load_balancing_credentials_should_skip_when_no_model_config( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: """Missing provider or model in node_data should be a no-op.""" # Arrange @@ -1788,10 +1794,10 @@ class TestWorkflowServiceCredentialValidation: node_data: dict[str, Any] = {} # no model key # Act + Assert (no error expected) - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=MagicMock()) + service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) def test_validate_load_balancing_credentials_should_skip_when_lb_not_enabled( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange workflow = self._make_workflow([]) @@ -1799,10 +1805,10 @@ class TestWorkflowServiceCredentialValidation: # Act + Assert (no error expected) with patch.object(service, "_is_load_balancing_enabled", return_value=False): - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=MagicMock()) + service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) def test_validate_load_balancing_credentials_should_raise_when_compliance_fails( - self, service: WorkflowService + self, service: WorkflowService, sqlite_session: Session ) -> None: # Arrange workflow = self._make_workflow([]) @@ -1819,7 +1825,7 @@ class TestWorkflowServiceCredentialValidation: ), ): with pytest.raises(ValueError, match="Invalid load balancing credentials"): - service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=MagicMock()) + service._validate_load_balancing_credentials(workflow, node_data, "node-1", session=sqlite_session) # =========================================================================== From 33acaa558e45d9613898185f00efe56f2ce266cf Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:18:56 +0900 Subject: [PATCH 04/63] test: use SQLite sessions in core workflow (#39109) --- .../nodes/tool/test_tool_node_runtime.py | 8 +- .../core/workflow/test_node_runtime.py | 203 ++++++++++++------ 2 files changed, 142 insertions(+), 69 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 501225fdbab..4b41e28ec8e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -6,6 +6,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import sessionmaker from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError @@ -26,7 +28,7 @@ from tests.workflow_test_utils import build_test_graph_init_params, build_test_v @pytest.fixture -def runtime(monkeypatch) -> DifyToolNodeRuntime: +def runtime(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> DifyToolNodeRuntime: module_name = "core.ops.ops_trace_manager" if module_name not in sys.modules: ops_stub = types.ModuleType(module_name) @@ -44,9 +46,7 @@ def runtime(monkeypatch) -> DifyToolNodeRuntime: invoke_from="debugger", call_depth=0, ) - session_maker = MagicMock() - session_maker.begin.return_value.__enter__.return_value = MagicMock(name="session") - session_maker.begin.return_value.__exit__.return_value = None + session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) return DifyToolNodeRuntime(init_params.run_context, session_maker=session_maker) diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 6190c7fb91c..adfd7ed2c5f 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -1,8 +1,12 @@ +from collections.abc import Iterator +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, Mock, sentinel from uuid import uuid4 import pytest +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access @@ -44,9 +48,62 @@ from graphon.model_runtime.model_providers.base.large_language_model import Larg from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.variables.segments import ArrayFileSegment, FileSegment +from models.base import TypeBase +from models.dataset import SegmentAttachmentBinding +from models.enums import CreatorUserRole +from models.model import StorageType, UploadFile from tests.workflow_test_utils import build_test_run_context +@pytest.fixture +def attachment_session(sqlite_engine: Engine) -> Iterator[Session]: + """Provide real attachment and upload-file persistence to node runtime tests.""" + + TypeBase.metadata.create_all(sqlite_engine, tables=[SegmentAttachmentBinding.__table__, UploadFile.__table__]) + with Session(sqlite_engine, expire_on_commit=False) as session: + yield session + + +def _persist_attachment( + session: Session, + *, + segment_id: str, + upload_file_id: str, + upload_file_tenant_id: str = "tenant-id", +) -> UploadFile: + """Persist an attachment binding for the test tenant and its referenced upload file.""" + + upload_file = UploadFile( + tenant_id=upload_file_tenant_id, + storage_type=StorageType.LOCAL, + key="storage-key", + name="diagram.png", + size=128, + extension="png", + mime_type="image/png", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user-id", + created_at=datetime.now(UTC).replace(tzinfo=None), + used=False, + source_url="https://example.com/diagram.png", + ) + upload_file.id = upload_file_id + session.add_all( + [ + upload_file, + SegmentAttachmentBinding( + tenant_id="tenant-id", + dataset_id="dataset-id", + document_id="document-id", + segment_id=segment_id, + attachment_id=upload_file_id, + ), + ] + ) + session.commit() + return upload_file + + def _build_model_schema(*, features: list[ModelFeature] | None = None) -> AIModelEntity: return AIModelEntity( model="gpt-4o-mini", @@ -348,29 +405,12 @@ def test_dify_prompt_message_serializer_delegates(monkeypatch: pytest.MonkeyPatc ) -def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest.MonkeyPatch) -> None: - upload_file = SimpleNamespace( - id="upload-file-id", - name="diagram.png", - extension="png", - mime_type="image/png", - source_url="https://example.com/diagram.png", - key="storage-key", - size=128, - ) - session = MagicMock() - session.execute.return_value.all.return_value = [(None, upload_file)] - - class _SessionContext: - def __enter__(self): - return session - - def __exit__(self, exc_type, exc, tb): - return False - +def test_dify_retriever_attachment_loader_builds_graph_files( + monkeypatch: pytest.MonkeyPatch, attachment_session: Session +) -> None: + _persist_attachment(attachment_session, segment_id="segment-id", upload_file_id="upload-file-id") build_from_mapping = MagicMock(return_value=sentinel.file) - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(node_runtime, "Session", MagicMock(return_value=_SessionContext())) + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=attachment_session.get_bind())) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) ) @@ -388,39 +428,18 @@ def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segment( monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, ) -> None: from factories.file_factory import builders as file_builders upload_file_id = str(uuid4()) segment_id = str(uuid4()) - upload_file = SimpleNamespace( - id=upload_file_id, - tenant_id="tenant-id", - name="diagram.png", - extension="png", - mime_type="image/png", - source_url="https://example.com/diagram.png", - key="storage-key", - size=128, - ) - attachment_session = MagicMock() - attachment_session.execute.return_value.all.return_value = [(None, upload_file)] - - class _AttachmentSessionContext: - def __enter__(self): - return attachment_session - - def __exit__(self, exc_type, exc, tb): - return False - - upload_session = MagicMock() - upload_session.__enter__.return_value = upload_session - upload_session.__exit__.return_value = False - upload_session.scalar.return_value = upload_file - - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(node_runtime, "Session", MagicMock(return_value=_AttachmentSessionContext())) - monkeypatch.setattr(file_builders, "session_factory", SimpleNamespace(create_session=lambda: upload_session)) + _persist_attachment(attachment_session, segment_id=segment_id, upload_file_id=upload_file_id) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + session_maker = sessionmaker(engine, expire_on_commit=False) + monkeypatch.setattr(file_builders.session_factory, "create_session", session_maker) loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) scope = FileAccessScope( @@ -435,18 +454,57 @@ def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segmen files = loader.load(segment_id=segment_id) assert files[0].related_id == upload_file_id - stmt = upload_session.scalar.call_args.args[0] - whereclause = str(stmt.whereclause) - assert "upload_files.tenant_id" in whereclause - assert "upload_files.id IN" in whereclause + assert files[0].filename == "diagram.png" + + +def test_dify_retriever_attachment_loader_rejects_granted_upload_file_from_another_tenant( + monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, +) -> None: + from factories.file_factory import builders as file_builders + + upload_file_id = str(uuid4()) + segment_id = str(uuid4()) + _persist_attachment( + attachment_session, + segment_id=segment_id, + upload_file_id=upload_file_id, + upload_file_tenant_id="other-tenant-id", + ) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(file_builders.session_factory, "create_session", sessionmaker(engine, expire_on_commit=False)) + + loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) + scope = FileAccessScope( + tenant_id="tenant-id", + user_id="end-user-id", + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + grant_retriever_segment_access([segment_id]) + with pytest.raises(ValueError, match="Invalid upload file"): + loader.load(segment_id=segment_id) def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, ) -> None: build_from_mapping = MagicMock() - session_factory = MagicMock() - monkeypatch.setattr(node_runtime, "Session", session_factory) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + statement_count = 0 + + def count_statements(*_args, **_kwargs) -> None: + nonlocal statement_count + statement_count += 1 + + event.listen(engine, "before_cursor_execute", count_statements) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) ) @@ -460,19 +518,31 @@ def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( with bind_file_access_scope(scope): files = loader.load(segment_id=str(uuid4())) - assert files == [] - session_factory.assert_not_called() - build_from_mapping.assert_not_called() + try: + assert files == [] + assert statement_count == 0 + build_from_mapping.assert_not_called() + finally: + event.remove(engine, "before_cursor_execute", count_statements) def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( monkeypatch: pytest.MonkeyPatch, + attachment_session: Session, ) -> None: segment_id = str(uuid4()) build_from_mapping = MagicMock() - session_factory = MagicMock() segment_access_checker = MagicMock(return_value=False) - monkeypatch.setattr(node_runtime, "Session", session_factory) + engine = attachment_session.get_bind() + assert engine is not None + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + statement_count = 0 + + def count_statements(*_args, **_kwargs) -> None: + nonlocal statement_count + statement_count += 1 + + event.listen(engine, "before_cursor_execute", count_statements) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping), segment_access_checker=segment_access_checker, @@ -488,10 +558,13 @@ def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( grant_retriever_segment_access([segment_id]) files = loader.load(segment_id=segment_id) - assert files == [] - segment_access_checker.assert_called_once_with(segment_id) - session_factory.assert_not_called() - build_from_mapping.assert_not_called() + try: + assert files == [] + segment_access_checker.assert_called_once_with(segment_id) + assert statement_count == 0 + build_from_mapping.assert_not_called() + finally: + event.remove(engine, "before_cursor_execute", count_statements) def test_dify_tool_file_manager_resolves_conversation_id_for_tool_files(monkeypatch: pytest.MonkeyPatch) -> None: From e9bd8741f42a66d06dcdb7bfd8d0c061c5c9a2af Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:23:37 +0900 Subject: [PATCH 05/63] test: use sqlite3 session in test_auth_wraps (#38760) --- .../controllers/inner_api/test_auth_wraps.py | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py index 96f1dcaed56..324d66c0b64 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py @@ -2,10 +2,12 @@ Unit tests for inner_api auth decorators """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch +from uuid import NAMESPACE_URL, uuid5 import pytest from flask import Flask +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import HTTPException from configs import dify_config @@ -16,9 +18,14 @@ from controllers.inner_api.wraps import ( inner_api_only, plugin_inner_api_only, ) +from models.enums import EndUserType from models.model import EndUser +def _stable_uuid(value: str) -> str: + return str(uuid5(NAMESPACE_URL, value)) + + class TestBillingInnerApiOnly: """Test billing_inner_api_only decorator""" @@ -258,7 +265,7 @@ class TestEnterpriseInnerApiUserAuth: assert result == "no_user" def test_should_pass_through_when_hmac_signature_invalid(self, app: Flask): - """Test that request passes through when HMAC signature is invalid""" + """Invalid HMAC auth passes through without opening a database session.""" # Arrange @enterprise_inner_api_user_auth @@ -277,7 +284,8 @@ class TestEnterpriseInnerApiUserAuth: assert result == "no_user" mock_create_session.assert_not_called() - def test_should_inject_user_when_hmac_signature_valid(self, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(EndUser,)], indirect=True) + def test_should_inject_user_when_hmac_signature_valid(self, app: Flask, sqlite_session: Session): """Test that user is injected when HMAC signature is valid""" # Arrange from base64 import b64encode @@ -289,19 +297,25 @@ class TestEnterpriseInnerApiUserAuth: return kwargs.get("user") # Calculate valid HMAC signature - user_id = "user123" + user_id = _stable_uuid("end-user:user123") inner_api_key = "valid_key" data_to_sign = f"DIFY {user_id}" signature = hmac_new(inner_api_key.encode("utf-8"), data_to_sign.encode("utf-8"), sha1) valid_signature = b64encode(signature.digest()).decode("utf-8") - # Create mock user - mock_user = MagicMock() - mock_user.id = user_id - mock_session = MagicMock() - mock_session.get.return_value = mock_user - mock_session_context = MagicMock() - mock_session_context.__enter__.return_value = mock_session + end_user = EndUser( + id=user_id, + tenant_id=_stable_uuid("tenant:inner-api"), + type=EndUserType.BROWSER, + name="Inner API User", + session_id="inner-api-session", + ) + sqlite_session.add(end_user) + sqlite_session.commit() + database_session_factory = sessionmaker( + bind=sqlite_session.get_bind(), + expire_on_commit=False, + ) # Act with app.test_request_context( @@ -310,14 +324,15 @@ class TestEnterpriseInnerApiUserAuth: with patch.object(dify_config, "INNER_API", True): with patch( "controllers.inner_api.wraps.session_factory.create_session", - return_value=mock_session_context, - ) as mock_create_session: + database_session_factory, + ): result = protected_view() # Assert - assert result == mock_user - mock_create_session.assert_called_once_with() - mock_session.get.assert_called_once_with(EndUser, user_id) + assert isinstance(result, EndUser) + assert result.id == end_user.id + assert result.tenant_id == end_user.tenant_id + assert result.session_id == "inner-api-session" class TestPluginInnerApiOnly: From 97489d5a4411eff40193ad93ad6078f064a75790 Mon Sep 17 00:00:00 2001 From: zcxGGmu <72263081+zcxGGmu@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:27:30 +0800 Subject: [PATCH 06/63] docs: note minimum Docker Compose version (#39374) Co-authored-by: zq --- README.md | 2 +- docker/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b6c430b6b32..7688ee889bd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Dify is an open-source LLM app development platform. Its intuitive interface com
-The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine: +The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and Docker Compose v2.24.0 or later are installed on your machine: ```bash cd dify diff --git a/docker/README.md b/docker/README.md index c3b1011bd68..0dedf718ef8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,7 +16,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T ### How to Deploy Dify with `docker-compose.yaml` -1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system. +1. **Prerequisites**: Ensure Docker and Docker Compose v2.24.0 or later are installed on your system. 2. **Environment Setup**: - Navigate to the `docker` directory. - Copy `.env.example` to `.env`. From f35655a5bbbcd09fcac296e4aa13d9dc3f056363 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:27:30 +0900 Subject: [PATCH 07/63] test: use sqlite3 session in test_human_input_forms (#38777) --- .../core/workflow/test_human_input_forms.py | 81 ++++++++++++------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/test_human_input_forms.py b/api/tests/unit_tests/core/workflow/test_human_input_forms.py index c84c7d578be..8d8c7d4ea7b 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_forms.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_forms.py @@ -1,6 +1,7 @@ -from types import SimpleNamespace +from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from core.workflow.human_input_forms import ( load_form_dispositions_by_form_id, @@ -11,19 +12,24 @@ from core.workflow.human_input_policy import ( HumanInputSurface, disposition_for_surface, ) -from models.human_input import RecipientType +from models.human_input import HumanInputFormRecipient, RecipientType + +TABLES = (HumanInputFormRecipient,) -class _FakeSession: - def __init__(self, recipients: list[SimpleNamespace]) -> None: - self._recipients = recipients - - def scalars(self, _stmt): - return self._recipients +def _recipient(form_id: str, recipient_type: RecipientType, access_token: str) -> HumanInputFormRecipient: + return HumanInputFormRecipient( + form_id=form_id, + delivery_id=str(uuid4()), + recipient_type=recipient_type, + recipient_payload="{}", + access_token=access_token, + ) -def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | None) -> SimpleNamespace: - return SimpleNamespace(form_id=form_id, recipient_type=recipient_type, access_token=access_token) +def _persist_recipients(session: Session, recipients: list[HumanInputFormRecipient]) -> None: + session.add_all(recipients) + session.commit() @pytest.mark.parametrize( @@ -35,60 +41,75 @@ def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | (HumanInputSurface.SERVICE_API, "web-token"), ], ) -def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_form_tokens_picks_token_for_surface(surface, expected_token, sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), _recipient("form-1", RecipientType.CONSOLE, "console-token"), _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), - ] + _recipient("form-2", RecipientType.BACKSTAGE, "decoy-token"), + ], ) - assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token} + assert load_form_tokens_by_form_id(["form-1"], session=sqlite_session, surface=surface) == { + "form-1": expected_token + } -def test_load_form_tokens_drops_forms_without_actionable_token() -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_form_tokens_drops_forms_without_actionable_token(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"), - _recipient("form-1", RecipientType.CONSOLE, None), - ] + _recipient("form-1", RecipientType.CONSOLE, ""), + ], ) - assert load_form_tokens_by_form_id(["form-1"], session=session) == {} + assert load_form_tokens_by_form_id(["form-1"], session=sqlite_session) == {} -def test_load_form_tokens_service_api_surface_uses_web_token() -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_form_tokens_service_api_surface_uses_web_token(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), _recipient("form-1", RecipientType.CONSOLE, "console-token"), _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), - ] + ], ) - assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == { + assert load_form_tokens_by_form_id(["form-1"], session=sqlite_session, surface=HumanInputSurface.SERVICE_API) == { "form-1": "web-token" } -def test_load_dispositions_openapi_webapp_form_is_resumable() -> None: - session = _FakeSession( +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_dispositions_openapi_webapp_form_is_resumable(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, [ _recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"), _recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"), - ] + ], ) - assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == { + assert load_form_dispositions_by_form_id(["form-1"], session=sqlite_session, surface=HumanInputSurface.OPENAPI) == { "form-1": FormDisposition(form_token="web-token", approval_channels=["console"]) } -def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token() -> None: - session = _FakeSession([_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")]) +@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True) +def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token(sqlite_session: Session) -> None: + _persist_recipients( + sqlite_session, + [_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")], + ) - assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == { + assert load_form_dispositions_by_form_id(["form-1"], session=sqlite_session, surface=HumanInputSurface.OPENAPI) == { "form-1": FormDisposition(form_token=None, approval_channels=["console"]) } From 79a5b31bc6c9f9749b7515e5d5726319451d20f1 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 11:27:55 +0900 Subject: [PATCH 08/63] test: move console OAuth coverage to unit tests (#38923) --- .../controllers/console/auth/test_oauth.py | 61 +++++-------------- 1 file changed, 15 insertions(+), 46 deletions(-) rename api/tests/{test_containers_integration_tests => unit_tests}/controllers/console/auth/test_oauth.py (92%) diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py similarity index 92% rename from api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py rename to api/tests/unit_tests/controllers/console/auth/test_oauth.py index d681bcfdce0..6964157189d 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -1,4 +1,4 @@ -"""Testcontainers integration tests for OAuth controller endpoints.""" +"""Unit tests for OAuth controller endpoints.""" from __future__ import annotations @@ -16,15 +16,10 @@ from controllers.console.auth.oauth import ( ) from libs.oauth import OAuthUserInfo, encode_oauth_state from models.account import AccountStatus -from services.account_service import AccountService from services.errors.account import AccountRegisterError class TestGetOAuthProviders: - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.mark.parametrize( ("github_config", "google_config", "expected_github", "expected_google"), [ @@ -65,10 +60,6 @@ class TestOAuthLogin: def resource(self): return OAuthLogin() - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.fixture def mock_oauth_provider(self): provider = MagicMock() @@ -181,10 +172,6 @@ class TestOAuthCallback: def resource(self): return OAuthCallback() - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.fixture def oauth_setup(self): """Common OAuth setup for callback tests""" @@ -448,10 +435,6 @@ class TestOAuthCallback: class TestAccountGeneration: - @pytest.fixture - def app(self, flask_app_with_containers: Flask): - return flask_app_with_containers - @pytest.fixture def user_info(self): return OAuthUserInfo(id="123", name="Test User", email="test@example.com") @@ -468,39 +451,25 @@ class TestAccountGeneration: self, mock_account_model, mock_get_account, - flask_req_ctx_with_containers, + app: Flask, user_info: OAuthUserInfo, mock_account, ): - # Test OpenID found - mock_account_model.get_by_openid.return_value = mock_account - result = _get_account_by_openid_or_email("github", user_info) - assert result == mock_account - mock_account_model.get_by_openid.assert_called_once_with("github", "123") - mock_get_account.assert_not_called() + with app.test_request_context("/"): + # Test OpenID found + mock_account_model.get_by_openid.return_value = mock_account + result = _get_account_by_openid_or_email("github", user_info) + assert result == mock_account + mock_account_model.get_by_openid.assert_called_once_with("github", "123") + mock_get_account.assert_not_called() - # Test fallback to email lookup - mock_account_model.get_by_openid.return_value = None - mock_get_account.return_value = mock_account + # Test fallback to email lookup + mock_account_model.get_by_openid.return_value = None + mock_get_account.return_value = mock_account - result = _get_account_by_openid_or_email("github", user_info) - assert result == mock_account - mock_get_account.assert_called_once() - - def test_get_account_by_email_with_case_fallback_falls_back_to_lowercase(self): - """Test that case fallback tries lowercase when exact match fails.""" - mock_session = MagicMock() - first_result = MagicMock() - first_result.scalar_one_or_none.return_value = None - expected_account = MagicMock() - second_result = MagicMock() - second_result.scalar_one_or_none.return_value = expected_account - mock_session.execute.side_effect = [first_result, second_result] - - result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com", session=mock_session) - - assert result is expected_account - assert mock_session.execute.call_count == 2 + result = _get_account_by_openid_or_email("github", user_info) + assert result == mock_account + mock_get_account.assert_called_once() @pytest.mark.parametrize( ("allow_register", "existing_account", "should_create"), From 3f0f57c5941beb2dbbe845c36fb34a9b6da4827b Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:56:08 -0700 Subject: [PATCH 09/63] fix: apply configured timeout to enterprise inner API requests (#39335) --- api/services/enterprise/base.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index 96c362b3dfc..5ddb51b0696 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -5,6 +5,7 @@ from typing import Any import httpx +from configs import dify_config from core.helper.trace_id_helper import generate_traceparent_header from services.errors.enterprise import ( EnterpriseAPIBadRequestError, @@ -96,12 +97,14 @@ class BaseRequest: logger.debug("Failed to generate traceparent header", exc_info=True) with httpx.Client(mounts=mounts) as client: - # IMPORTANT: - # - In httpx, passing timeout=None disables timeouts (infinite) and overrides the library default. - # - To preserve httpx's default timeout behavior for existing call sites, only pass the kwarg when set. - request_kwargs: dict[str, Any] = {"json": json, "params": params, "headers": headers} - if timeout is not None: - request_kwargs["timeout"] = timeout + # Callers that pass an explicit timeout keep it; everyone else gets the + # configured budget rather than httpx's implicit 5s default. + request_kwargs: dict[str, Any] = { + "json": json, + "params": params, + "headers": headers, + "timeout": timeout if timeout is not None else dify_config.ENTERPRISE_REQUEST_TIMEOUT, + } response = client.request(method, url, **request_kwargs) @@ -206,9 +209,8 @@ class EnterpriseRequest(BaseRequest): "json": json, "params": params, "headers": {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key, **inner_headers}, + "timeout": timeout if timeout is not None else dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT, } - if timeout is not None: - request_kwargs["timeout"] = timeout response = client.request(method, url, **request_kwargs) if not response.is_success: cls._handle_error_response(response) From 04f90267246cfa7d2f84bf3b75a77b02e7b1b80e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:32:40 +0800 Subject: [PATCH 10/63] fix(web): show unavailable state for disabled Web Apps (#39392) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- e2e/AGENTS.md | 18 ++++ e2e/features/agent-v2/support/access-point.ts | 5 +- e2e/features/apps/share-app.feature | 14 +-- e2e/features/apps/web-app-service.feature | 19 ++++ .../step-definitions/apps/share-app.steps.ts | 25 ----- .../apps/web-app-service.steps.ts | 101 ++++++++++++++++++ .../step-definitions/common/app.steps.ts | 8 +- e2e/features/support/hooks.ts | 1 + e2e/features/support/world.ts | 2 + e2e/support/api.ts | 43 +++++--- oxlint-suppressions.json | 8 -- .../components/__tests__/splash.spec.tsx | 36 ++++++- web/app/(shareLayout)/components/splash.tsx | 24 ++++- 13 files changed, 236 insertions(+), 68 deletions(-) create mode 100644 e2e/features/apps/web-app-service.feature create mode 100644 e2e/features/step-definitions/apps/web-app-service.steps.ts diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 8aae18c6e75..8771bd27d68 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -195,6 +195,24 @@ Open the HTML report locally with: open cucumber-report/report.html ``` +## Scenario admission and behavior ownership + +Add an E2E scenario only when it protects a critical user journey and a cross-boundary result that +cheaper owner-level tests do not already prove. A control changing its own label is not sufficient +E2E evidence when component or integration tests can own that contract. + +Start from product truth, including real defaults and actor roles. API fixtures may establish +preconditions, but they must not manufacture an opposite state merely to make the intended action +look meaningful. When a product default is part of the journey, make it explicit and observable. + +For cross-actor journeys, isolate each actor's browser state, keep their pages in typed `DifyWorld` +state, and include them in failure diagnostics and cleanup. Assert the downstream user-observable +effect, not only the initiating control's local state. + +When a run exposes behavior that conflicts with the intended product contract, identify the first +layer that misclassifies the business state. Fix that owner or report the mismatch explicitly; do +not make the E2E pass by encoding an accidental redirect, stale label, or misleading error state. + ## Writing new scenarios ### Workflow diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index 96316028b3b..fa0b53e5b25 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -47,8 +47,9 @@ export async function setAgentSiteAccessAndGetURL( if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) const appDetail = await setAppSiteEnabled(appId, enabled) - const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token - const baseURL = agent.site?.app_base_url ?? appDetail.site.app_base_url + const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site?.access_token + const baseURL = agent.site?.app_base_url ?? appDetail.site?.app_base_url + if (!token || !baseURL) throw new Error(`Agent v2 ${agentId} does not expose a Web App URL.`) return `${baseURL.replace(/\/$/, '')}/agent/${token}` } diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature index 265599ecd16..36399c4123b 100644 --- a/e2e/features/apps/share-app.feature +++ b/e2e/features/apps/share-app.feature @@ -1,17 +1,5 @@ @apps @core -Feature: Share app publicly - - @authenticated - Scenario: Enable public share for a published workflow app - Given I am signed in as the default E2E admin - And a "workflow" app has been created via API - And a minimal runnable workflow draft has been synced - When I open the app from the app list - And I open the publish panel - And I publish the app - And I navigate to the app overview page - And I enable the Web App share - Then the Web App should be in service +Feature: Use a shared workflow app @unauthenticated Scenario: Access a shared workflow app without authentication diff --git a/e2e/features/apps/web-app-service.feature b/e2e/features/apps/web-app-service.feature new file mode 100644 index 00000000000..f027ed3e9da --- /dev/null +++ b/e2e/features/apps/web-app-service.feature @@ -0,0 +1,19 @@ +@apps @authenticated @core +Feature: Manage Web App service + + Scenario: Disable and restore a published workflow Web App + Given I am signed in as the default E2E admin + And a new runnable workflow app has been published + When I navigate to the app overview page + And I open the app information panel + Then the Web App should be in service + When an anonymous visitor opens the Web App + Then the published workflow Web App should be accessible + When I disable the Web App + Then the Web App should be disabled + When the anonymous visitor reloads the Web App + Then the published workflow Web App should be unavailable + When I enable the Web App + Then the Web App should be in service + When the anonymous visitor reloads the Web App + Then the published workflow Web App should be accessible diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index b216b518696..d34e227ace0 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -9,31 +9,6 @@ import { } from '../../../support/api' import { createE2EResourceName } from '../../../support/naming' -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -When('I enable the Web App share', async function (this: DifyWorld) { - const page = this.getPage() - const appName = this.lastCreatedAppName - if (!appName) { - throw new Error( - 'No app name available. Run "a \\"workflow\\" app has been created via API" first.', - ) - } - - await page.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click() - const webAppCard = page.getByRole('region', { name: 'Web App' }) - const webAppSwitch = webAppCard.getByRole('switch', { name: 'Web App' }) - await expect(webAppSwitch).toBeEnabled({ timeout: 15_000 }) - await webAppSwitch.click() -}) - -Then('the Web App should be in service', async function (this: DifyWorld) { - const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) - await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ - timeout: 10_000, - }) -}) - Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { const app = await createTestApp(createE2EResourceName('App', 'Share'), 'workflow') this.createdAppIds.push(app.id) diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts new file mode 100644 index 00000000000..aee5962b98c --- /dev/null +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -0,0 +1,101 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { + createTestApp, + getAppSiteDetail, + getAppSiteURL, + publishWorkflowApp, + syncRunnableWorkflowDraft, +} from '../../../support/api' +import { createE2EResourceName } from '../../../support/naming' +import { baseURL, defaultLocale } from '../../../test-env' + +Given('a new runnable workflow app has been published', async function (this: DifyWorld) { + const app = await createTestApp(createE2EResourceName('App', 'WebApp'), 'workflow') + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name + await syncRunnableWorkflowDraft(app.id) + await publishWorkflowApp(app.id) + + const appDetail = await getAppSiteDetail(app.id) + expect(appDetail.enable_site).toBe(true) + this.shareURL = getAppSiteURL(appDetail) +}) + +When('I open the app information panel', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) { + throw new Error('No app name available. Create an app before opening its information panel.') + } + + await this.getPage().getByRole('button', { name: appName }).click() +}) + +const getWebAppSwitch = (world: DifyWorld) => { + const webAppCard = world.getPage().getByRole('region', { name: 'Web App' }) + return webAppCard.getByRole('switch', { name: 'Web App' }) +} + +When('an anonymous visitor opens the Web App', async function (this: DifyWorld) { + if (!this.shareURL) throw new Error('No Web App URL is available.') + if (!this.context) throw new Error('Playwright browser context has not been initialized.') + + const browser = this.context.browser() + if (!browser) throw new Error('Playwright browser has not been initialized.') + + const anonymousContext = await browser.newContext({ baseURL, locale: defaultLocale }) + this.registerCleanup(() => anonymousContext.close()) + this.sharedAppPage = await anonymousContext.newPage() + await this.sharedAppPage.goto(this.shareURL, { timeout: 20_000 }) +}) + +When('the anonymous visitor reloads the Web App', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await this.sharedAppPage.reload({ timeout: 20_000 }) +}) + +When('I disable the Web App', async function (this: DifyWorld) { + const webAppSwitch = getWebAppSwitch(this) + + await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 }) + await expect(webAppSwitch).toHaveAttribute('aria-checked', 'true') + await webAppSwitch.click() +}) + +When('I enable the Web App', async function (this: DifyWorld) { + const webAppSwitch = getWebAppSwitch(this) + + await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 }) + await expect(webAppSwitch).toHaveAttribute('aria-checked', 'false') + await webAppSwitch.click() +}) + +Then('the Web App should be in service', async function (this: DifyWorld) { + const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) + await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ + timeout: 10_000, + }) +}) + +Then('the Web App should be disabled', async function (this: DifyWorld) { + const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) + await expect(webAppCard.getByText('Disabled', { exact: true })).toBeVisible({ + timeout: 10_000, + }) +}) + +Then('the published workflow Web App should be accessible', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await expect(this.sharedAppPage.getByRole('button', { name: 'Execute' })).toBeVisible({ + timeout: 15_000, + }) +}) + +Then('the published workflow Web App should be unavailable', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await expect(this.sharedAppPage.getByRole('heading', { name: '404' })).toBeVisible({ + timeout: 15_000, + }) + await expect(this.sharedAppPage.getByText('App is unavailable', { exact: true })).toBeVisible() +}) diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index 1fa50cbf0d1..a3399130f4e 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -12,15 +12,19 @@ Given('a {string} app has been created via API', async function (this: DifyWorld }) Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { - const appId = this.createdAppIds.at(-1)! + const appId = this.createdAppIds.at(-1) + if (!appId) throw new Error('No app is available for workflow draft setup.') await syncMinimalWorkflowDraft(appId) }) When('I open the app from the app list', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) throw new Error('No app is available to open from the app list.') + const page = this.getPage() await page.goto('/apps') await waitForAppsConsole(page) - const appLink = page.getByRole('link', { name: this.lastCreatedAppName!, exact: true }) + const appLink = page.getByRole('link', { name: appName, exact: true }) await expect(appLink).toBeVisible() await appLink.click() }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 0325d04a7a7..40c794c6b14 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -220,6 +220,7 @@ After( const artifactErrors: string[] = [] const diagnosticPages = uniqueDiagnosticPages([ { label: 'main-page', page: this.page }, + { label: 'shared-app', page: this.sharedAppPage }, { label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage }, { label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage }, { diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index d8b7f3ea472..53a13f40163 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -96,6 +96,7 @@ export class DifyWorld extends World { scenarioCleanups: ScenarioCleanup[] = [] capturedDownloads: Download[] = [] shareURL: string | undefined + sharedAppPage: Page | undefined constructor(options: IWorldOptions) { super(options) @@ -120,6 +121,7 @@ export class DifyWorld extends World { this.scenarioCleanups = [] this.capturedDownloads = [] this.shareURL = undefined + this.sharedAppPage = undefined } async startSession(browser: Browser, authenticated: boolean) { diff --git a/e2e/support/api.ts b/e2e/support/api.ts index a6dfde8ef17..3cc4175debc 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -1,5 +1,7 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { APIResponse } from '@playwright/test' import { readFile } from 'node:fs/promises' +import { zAppDetailWithSite } from '@dify/contracts/api/console/apps/zod.gen' import { request } from '@playwright/test' import { authStatePath } from '../fixtures/auth' import { apiURL } from '../test-env' @@ -82,7 +84,7 @@ export async function getWorkflowDraft(appId: string): Promise { export async function syncMinimalWorkflowDraft(appId: string): Promise { const ctx = await createApiContext() try { - await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data: { graph: { nodes: [ @@ -101,6 +103,7 @@ export async function syncMinimalWorkflowDraft(appId: string): Promise { conversation_variables: [], }, }) + await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`) } finally { await ctx.dispose() } @@ -164,7 +167,7 @@ export async function deleteTestApp(id: string): Promise { export async function syncRunnableWorkflowDraft(appId: string): Promise { const ctx = await createApiContext() try { - await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data: { graph: { nodes: [ @@ -203,6 +206,7 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise { conversation_variables: [], }, }) + await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`) } finally { await ctx.dispose() } @@ -211,22 +215,37 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise { export async function publishWorkflowApp(appId: string): Promise { const ctx = await createApiContext() try { - await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { + const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { data: { marked_name: '', marked_comment: '' }, }) + await expectApiResponseOK(response, `Publish workflow app ${appId}`) } finally { await ctx.dispose() } } -export type AppDetailWithSite = { - mode?: string - site: { access_token: string; app_base_url: string; enable_site: boolean } +export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { + if (!site?.app_base_url || !site.access_token) + throw new Error('App detail does not include a Web App URL.') + + const webAppMode = (() => { + if (mode === 'completion' || mode === 'workflow') return mode + if (mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'chat') return 'chat' + throw new Error(`Unsupported Web App mode: ${mode}`) + })() + + return `${site.app_base_url}/${webAppMode}/${site.access_token}` } -export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { - const webAppMode = mode === 'completion' || mode === 'workflow' ? mode : 'chat' - return `${site.app_base_url}/${webAppMode}/${site.access_token}` +export async function getAppSiteDetail(appId: string): Promise { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/apps/${appId}`) + await expectApiResponseOK(response, `Get app site detail for ${appId}`) + return zAppDetailWithSite.parse(await response.json()) + } finally { + await ctx.dispose() + } } export async function enableAppSiteAndGetURL(appId: string): Promise { @@ -243,11 +262,9 @@ export async function setAppSiteEnabled( data: { enable_site: enabled }, }) await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) - - const detailResponse = await ctx.get(`/console/api/apps/${appId}`) - await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`) - return (await detailResponse.json()) as AppDetailWithSite } finally { await ctx.dispose() } + + return getAppSiteDetail(appId) } diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c9e6e2ff30e..e8696cf7d38 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -157,14 +157,6 @@ "count": 1 } }, - "web/app/(shareLayout)/components/splash.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index 48d87b4438b..cf2f43f2417 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import Splash from '../splash' const navigationMocks = vi.hoisted(() => ({ @@ -42,7 +42,7 @@ vi.mock('@/service/share', () => ({ vi.mock('@/service/webapp-auth', () => webAppAuthMocks) -describe('Splash redirect security', () => { +describe('Splash', () => { beforeEach(() => { vi.clearAllMocks() webAppState.shareCode = 'share-app' @@ -121,4 +121,36 @@ describe('Splash redirect security', () => { expect(webAppAuthMocks.webAppLoginStatus).not.toHaveBeenCalled() expect(fetchAccessTokenMock).not.toHaveBeenCalled() }) + + it('should show the app unavailable state when a public Web App passport is not found', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue(new Response(null, { status: 404 })) + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share.common.appUnavailable')).toBeInTheDocument() + }) + + it('should expose the unavailable-state action as a button', () => { + navigationMocks.searchParams = new URLSearchParams({ + code: '404', + message: 'The Web App is unavailable.', + }) + + render( + +
share application
+
, + ) + + expect(screen.getByRole('button', { name: 'share.login.backToHome' })).toBeInTheDocument() + }) }) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 9f1967cb39f..e813f789507 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -57,6 +57,7 @@ function Splash({ children }: PropsWithChildren) { }, [getSigninUrl, pathname, redirectUrl, router, shareCode]) const [isLoading, setIsLoading] = useState(true) + const [unavailableShareCode, setUnavailableShareCode] = useState() useEffect(() => { const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin) const isSigninRoute = isWebAppSigninPath(pathname) @@ -101,7 +102,12 @@ function Splash({ children }: PropsWithChildren) { }) setWebAppPassport(effectiveShareCode, access_token) redirectOrFinish() - } catch { + } catch (error) { + if (error instanceof Response && error.status === 404) { + setUnavailableShareCode(effectiveShareCode) + await webAppLogout(effectiveShareCode) + return + } await webAppLogout(effectiveShareCode) proceedToAuth() } @@ -126,11 +132,23 @@ function Splash({ children }: PropsWithChildren) { code={code || t(($) => $['common.appUnavailable'], { ns: 'share' })} unknownReason={message} /> - + + + ) + } + + if (unavailableShareCode === shareCode) { + return ( +
+
) } From 84f3c410c4e2042ff5ffc9a5f30144c11edfe380 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:57:35 +0800 Subject: [PATCH 11/63] fix(datasets): update empty state handling and permissions messaging (#39360) --- .../datasets/list/__tests__/index.spec.tsx | 5 +- .../__tests__/index.spec.tsx | 9 +- .../datasets/list/first-empty-state/index.tsx | 87 ++++++++++--------- web/app/components/datasets/list/index.tsx | 6 +- web/i18n/ar-TN/dataset.json | 1 + web/i18n/de-DE/dataset.json | 1 + web/i18n/en-US/dataset.json | 1 + web/i18n/es-ES/dataset.json | 1 + web/i18n/fa-IR/dataset.json | 1 + web/i18n/fr-FR/dataset.json | 1 + web/i18n/hi-IN/dataset.json | 1 + web/i18n/id-ID/dataset.json | 1 + web/i18n/it-IT/dataset.json | 1 + web/i18n/ja-JP/dataset.json | 1 + web/i18n/ko-KR/dataset.json | 1 + web/i18n/nl-NL/dataset.json | 1 + web/i18n/pl-PL/dataset.json | 1 + web/i18n/pt-BR/dataset.json | 1 + web/i18n/ro-RO/dataset.json | 1 + web/i18n/ru-RU/dataset.json | 1 + web/i18n/sl-SI/dataset.json | 1 + web/i18n/th-TH/dataset.json | 1 + web/i18n/tr-TR/dataset.json | 1 + web/i18n/uk-UA/dataset.json | 1 + web/i18n/vi-VN/dataset.json | 1 + web/i18n/zh-Hans/dataset.json | 1 + web/i18n/zh-Hant/dataset.json | 1 + 27 files changed, 77 insertions(+), 53 deletions(-) diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index a1d6ba7fbf9..912e883309b 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -342,7 +342,7 @@ describe('List', () => { ).toHaveAttribute('href', '/datasets/create-from-pipeline') }) - it('should not render first empty state for legacy editors without dataset creation permissions', async () => { + it('should render a permission empty state without dataset creation permissions', async () => { mockConsoleState = { isCurrentWorkspaceEditor: true, isCurrentWorkspaceManager: true, @@ -361,7 +361,8 @@ describe('List', () => { render() expect(screen.queryByText('dataset.firstEmpty.title')).not.toBeInTheDocument() - expect(screen.getByTestId('datasets-component')).toBeInTheDocument() + expect(screen.getByText('dataset.firstEmpty.noCreatePermission')).toBeInTheDocument() + expect(screen.queryByTestId('datasets-component')).not.toBeInTheDocument() }) it('should not render first empty state before the first dataset page resolves', async () => { diff --git a/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx b/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx index 0119cf02421..6315bf835c8 100644 --- a/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx @@ -82,11 +82,10 @@ describe('DatasetFirstEmptyState', () => { ) }) - it('renders nothing when no empty-state action is available', () => { - const { container } = render( - , - ) + it('shows a permission message when no empty-state action is available', () => { + render() - expect(container).toBeEmptyDOMElement() + expect(screen.getByText('dataset.firstEmpty.noCreatePermission')).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/list/first-empty-state/index.tsx b/web/app/components/datasets/list/first-empty-state/index.tsx index bfe508a0a20..cb178f96853 100644 --- a/web/app/components/datasets/list/first-empty-state/index.tsx +++ b/web/app/components/datasets/list/first-empty-state/index.tsx @@ -67,8 +67,6 @@ function DatasetFirstEmptyState({ : undefined const hasActions = createActions.length > 0 || !!connectAction - if (!hasActions) return null - return (
@@ -89,47 +87,54 @@ function DatasetFirstEmptyState({
-

- {t(($) => $['firstEmpty.title'], { ns: 'dataset' })} +

+ {t(($) => $[hasActions ? 'firstEmpty.title' : 'firstEmpty.noCreatePermission'], { + ns: 'dataset', + })}

-
- {createActions.length > 0 && ( -
- {createActions.map((action) => ( - - ))} -
- )} - {createActions.length > 0 && connectAction && ( -
-
- - {t(($) => $['firstEmpty.or'], { ns: 'dataset' })} - -
-
- )} - {connectAction && ( - - )} -
+ {hasActions && ( +
+ {createActions.length > 0 && ( +
+ {createActions.map((action) => ( + + ))} +
+ )} + {createActions.length > 0 && connectAction && ( +
+
+ + {t(($) => $['firstEmpty.or'], { ns: 'dataset' })} + +
+
+ )} + {connectAction && ( + + )} +
+ )}
diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index ce8c775b2b5..6029e2b8845 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -91,11 +91,7 @@ const List = () => { keywords.trim().length > 0 || searchKeywords.trim().length > 0 || includeAll - const showEmptyDataList = - !hasAnyDataset && - (canCreateDataset || canConnectExternalDataset) && - hasResolvedFirstPage && - !hasActiveFilters + const showEmptyDataList = !hasAnyDataset && hasResolvedFirstPage && !hasActiveFilters const showFilteredEmptyState = !hasAnyDataset && hasResolvedFirstPage && hasActiveFilters const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) diff --git a/web/i18n/ar-TN/dataset.json b/web/i18n/ar-TN/dataset.json index 84001c9b298..809a78b746a 100644 --- a/web/i18n/ar-TN/dataset.json +++ b/web/i18n/ar-TN/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "هل لديك قاعدة معرفة بالفعل؟ اربطها عبر API دون ترحيل البيانات.", "firstEmpty.createDescription": "أسرع طريقة للبدء. يمكنك التبديل إلى التخصيص في أي وقت.", "firstEmpty.createTitle": "إنشاء قاعدة معرفة جاهزة للاستخدام", + "firstEmpty.noCreatePermission": "لا توجد قواعد معرفة يمكنك الوصول إليها.\nتواصل مع مشرف صيانة المورد أو مالك مساحة العمل لطلب الوصول.", "firstEmpty.or": "أو", "firstEmpty.pipelineDescription": "عرّف تدفق التقسيم والتنظيف والفهرسة الخاص بك للبيانات المتخصصة.", "firstEmpty.pipelineTitle": "إنشاء قاعدة معرفة مخصصة", diff --git a/web/i18n/de-DE/dataset.json b/web/i18n/de-DE/dataset.json index 63f81f38312..42e5381cd26 100644 --- a/web/i18n/de-DE/dataset.json +++ b/web/i18n/de-DE/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Hast du bereits eine Wissensdatenbank? Verbinde sie per API, ohne Daten zu migrieren.", "firstEmpty.createDescription": "Der schnellste Einstieg. Du kannst jederzeit zu benutzerdefiniert wechseln.", "firstEmpty.createTitle": "Einsatzbereite Wissensdatenbank erstellen", + "firstEmpty.noCreatePermission": "Keine zugänglichen Wissensdatenbanken.\nWende dich an den Ressourcenbetreuer oder Workspace-Inhaber, um Zugriff anzufordern.", "firstEmpty.or": "Oder", "firstEmpty.pipelineDescription": "Definiere eigene Chunking-, Bereinigungs- und Indexierungsabläufe für spezialisierte Daten.", "firstEmpty.pipelineTitle": "Benutzerdefinierte Wissensdatenbank erstellen", diff --git a/web/i18n/en-US/dataset.json b/web/i18n/en-US/dataset.json index 5c6e012a91c..8a6a099fc14 100644 --- a/web/i18n/en-US/dataset.json +++ b/web/i18n/en-US/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "Upload documents and let Dify handle the rest. The fastest way to get started.", "firstEmpty.createTitle": "Create a ready-to-use knowledge base", + "firstEmpty.noCreatePermission": "No accessible knowledge bases.\nContact the resource maintainer or your Workspace Owner to request access.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "Build a custom data processing workflow with flexible nodes and steps.", "firstEmpty.pipelineTitle": "Build a custom knowledge base", diff --git a/web/i18n/es-ES/dataset.json b/web/i18n/es-ES/dataset.json index 54b080e555b..72f17df723f 100644 --- a/web/i18n/es-ES/dataset.json +++ b/web/i18n/es-ES/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "¿Ya tienes una base de conocimiento? Conéctala mediante API sin migrar datos.", "firstEmpty.createDescription": "La forma más rápida de empezar. Puedes cambiar a personalizado en cualquier momento.", "firstEmpty.createTitle": "Crear una base de conocimiento lista para usar", + "firstEmpty.noCreatePermission": "No hay bases de conocimiento accesibles.\nContacta con el mantenedor del recurso o el propietario del espacio de trabajo para solicitar acceso.", "firstEmpty.or": "O", "firstEmpty.pipelineDescription": "Define tu propio flujo de fragmentación, limpieza e indexación para datos especializados.", "firstEmpty.pipelineTitle": "Crear una base de conocimiento personalizada", diff --git a/web/i18n/fa-IR/dataset.json b/web/i18n/fa-IR/dataset.json index 1e4a043329b..1a52b1ec7c6 100644 --- a/web/i18n/fa-IR/dataset.json +++ b/web/i18n/fa-IR/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "ایجاد پایگاه دانش آماده استفاده", + "firstEmpty.noCreatePermission": "هیچ پایگاه دانشی در دسترس نیست.\nبرای درخواست دسترسی با نگهدارنده منبع یا مالک فضای کاری تماس بگیرید.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "جریان قطعه‌بندی، پاک‌سازی و نمایه‌سازی خود را برای داده‌های تخصصی تعریف کنید.", "firstEmpty.pipelineTitle": "ساخت پایگاه دانش سفارشی", diff --git a/web/i18n/fr-FR/dataset.json b/web/i18n/fr-FR/dataset.json index d1d6eb73690..74f35ad9776 100644 --- a/web/i18n/fr-FR/dataset.json +++ b/web/i18n/fr-FR/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Vous avez déjà une base de connaissances ? Connectez-la via API sans migrer les données.", "firstEmpty.createDescription": "La façon la plus rapide de commencer. Vous pouvez passer au personnalisé à tout moment.", "firstEmpty.createTitle": "Créer une base de connaissances prête à l’emploi", + "firstEmpty.noCreatePermission": "Aucune base de connaissances accessible.\nContactez le mainteneur de la ressource ou le propriétaire de l’espace de travail pour demander l’accès.", "firstEmpty.or": "Ou", "firstEmpty.pipelineDescription": "Définissez votre propre flux de découpage, nettoyage et indexation pour des données spécialisées.", "firstEmpty.pipelineTitle": "Créer une base de connaissances personnalisée", diff --git a/web/i18n/hi-IN/dataset.json b/web/i18n/hi-IN/dataset.json index 6cbc6872062..44b0e92b5e4 100644 --- a/web/i18n/hi-IN/dataset.json +++ b/web/i18n/hi-IN/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "उपयोग के लिए तैयार नॉलेज बेस बनाएं", + "firstEmpty.noCreatePermission": "कोई सुलभ नॉलेज बेस नहीं है।\nऐक्सेस का अनुरोध करने के लिए संसाधन के रखरखावकर्ता या वर्कस्पेस के मालिक से संपर्क करें।", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "विशेष डेटा के लिए अपना चंकिंग, क्लीनअप और इंडेक्सिंग फ़्लो परिभाषित करें.", "firstEmpty.pipelineTitle": "कस्टम नॉलेज बेस बनाएं", diff --git a/web/i18n/id-ID/dataset.json b/web/i18n/id-ID/dataset.json index cfcc2b078b9..dee2bf50d21 100644 --- a/web/i18n/id-ID/dataset.json +++ b/web/i18n/id-ID/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "Buat basis pengetahuan siap pakai", + "firstEmpty.noCreatePermission": "Tidak ada basis pengetahuan yang dapat diakses.\nHubungi pengelola sumber daya atau Pemilik Workspace untuk meminta akses.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "Tentukan alur chunking, pembersihan, dan pengindeksan Anda sendiri untuk data khusus.", "firstEmpty.pipelineTitle": "Bangun basis pengetahuan kustom", diff --git a/web/i18n/it-IT/dataset.json b/web/i18n/it-IT/dataset.json index 4683043b2dc..f6871d8e8bb 100644 --- a/web/i18n/it-IT/dataset.json +++ b/web/i18n/it-IT/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Hai già una knowledge base? Collegala via API senza migrare i dati.", "firstEmpty.createDescription": "Il modo più rapido per iniziare. Puoi passare al personalizzato in qualsiasi momento.", "firstEmpty.createTitle": "Crea una knowledge base pronta all’uso", + "firstEmpty.noCreatePermission": "Nessuna knowledge base accessibile.\nContatta il manutentore della risorsa o il proprietario del workspace per richiedere l’accesso.", "firstEmpty.or": "Oppure", "firstEmpty.pipelineDescription": "Definisci il tuo flusso di suddivisione, pulizia e indicizzazione per dati specializzati.", "firstEmpty.pipelineTitle": "Crea una knowledge base personalizzata", diff --git a/web/i18n/ja-JP/dataset.json b/web/i18n/ja-JP/dataset.json index 22c87f51004..55cb4cdeca5 100644 --- a/web/i18n/ja-JP/dataset.json +++ b/web/i18n/ja-JP/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "既にナレッジベースをお持ちですか?データを移行せずに API 経由で接続できます。", "firstEmpty.createDescription": "ドキュメントをアップロードするだけで、あとは Dify にお任せ。最も手軽な始め方です。", "firstEmpty.createTitle": "すぐに使えるナレッジベースを作成", + "firstEmpty.noCreatePermission": "アクセス可能なナレッジベースがありません。\nアクセスを申請するには、リソースのメンテナーまたはワークスペースのオーナーにお問い合わせください。", "firstEmpty.or": "または", "firstEmpty.pipelineDescription": "柔軟なノードとステップで、カスタムのデータ処理ワークフローを構築できます。", "firstEmpty.pipelineTitle": "カスタムナレッジベースを構築", diff --git a/web/i18n/ko-KR/dataset.json b/web/i18n/ko-KR/dataset.json index 7abd4fcedd7..d7c36a07186 100644 --- a/web/i18n/ko-KR/dataset.json +++ b/web/i18n/ko-KR/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "이미 지식 베이스가 있나요? 데이터를 이전하지 않고 API로 연결하세요.", "firstEmpty.createDescription": "가장 빠르게 시작하는 방법입니다. 언제든 사용자 지정으로 전환할 수 있습니다.", "firstEmpty.createTitle": "바로 사용할 수 있는 지식 베이스 만들기", + "firstEmpty.noCreatePermission": "액세스할 수 있는 지식 베이스가 없습니다.\n액세스를 요청하려면 리소스 관리자 또는 워크스페이스 소유자에게 문의하세요.", "firstEmpty.or": "또는", "firstEmpty.pipelineDescription": "전문 데이터에 맞게 청킹, 정리, 인덱싱 흐름을 직접 정의하세요.", "firstEmpty.pipelineTitle": "사용자 지정 지식 베이스 만들기", diff --git a/web/i18n/nl-NL/dataset.json b/web/i18n/nl-NL/dataset.json index 09f5dfd4ceb..da3020af4c7 100644 --- a/web/i18n/nl-NL/dataset.json +++ b/web/i18n/nl-NL/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "Een gebruiksklare kennisbank maken", + "firstEmpty.noCreatePermission": "Er zijn geen toegankelijke kennisbanken.\nNeem contact op met de beheerder van de resource of de eigenaar van de workspace om toegang aan te vragen.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "Definieer je eigen flow voor chunking, opschoning en indexering voor gespecialiseerde data.", "firstEmpty.pipelineTitle": "Een aangepaste kennisbank bouwen", diff --git a/web/i18n/pl-PL/dataset.json b/web/i18n/pl-PL/dataset.json index c05812f8da5..61028b11788 100644 --- a/web/i18n/pl-PL/dataset.json +++ b/web/i18n/pl-PL/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "Utwórz gotową do użycia bazę wiedzy", + "firstEmpty.noCreatePermission": "Brak dostępnych baz wiedzy.\nSkontaktuj się z opiekunem zasobu lub właścicielem obszaru roboczego, aby poprosić o dostęp.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "Zdefiniuj własny przepływ dzielenia na fragmenty, czyszczenia i indeksowania dla specjalistycznych danych.", "firstEmpty.pipelineTitle": "Zbuduj niestandardową bazę wiedzy", diff --git a/web/i18n/pt-BR/dataset.json b/web/i18n/pt-BR/dataset.json index 19a8475b388..c35099ff91c 100644 --- a/web/i18n/pt-BR/dataset.json +++ b/web/i18n/pt-BR/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Já tem uma base de conhecimento? Conecte-a via API sem migrar dados.", "firstEmpty.createDescription": "A forma mais rápida de começar. Você pode mudar para personalizado a qualquer momento.", "firstEmpty.createTitle": "Criar uma base de conhecimento pronta para uso", + "firstEmpty.noCreatePermission": "Nenhuma base de conhecimento acessível.\nEntre em contato com o mantenedor do recurso ou o proprietário do workspace para solicitar acesso.", "firstEmpty.or": "Ou", "firstEmpty.pipelineDescription": "Defina seu próprio fluxo de divisão, limpeza e indexação para dados especializados.", "firstEmpty.pipelineTitle": "Criar uma base de conhecimento personalizada", diff --git a/web/i18n/ro-RO/dataset.json b/web/i18n/ro-RO/dataset.json index 1e17c8fdd99..c340e2476a5 100644 --- a/web/i18n/ro-RO/dataset.json +++ b/web/i18n/ro-RO/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "Creează o bază de cunoștințe gata de utilizare", + "firstEmpty.noCreatePermission": "Nu există baze de cunoștințe accesibile.\nContactează întreținătorul resursei sau proprietarul spațiului de lucru pentru a solicita acces.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "Definește propriul flux de fragmentare, curățare și indexare pentru date specializate.", "firstEmpty.pipelineTitle": "Construiește o bază de cunoștințe personalizată", diff --git a/web/i18n/ru-RU/dataset.json b/web/i18n/ru-RU/dataset.json index 43690351a2c..5251f291f63 100644 --- a/web/i18n/ru-RU/dataset.json +++ b/web/i18n/ru-RU/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "У вас уже есть база знаний? Подключите ее через API без миграции данных.", "firstEmpty.createDescription": "Самый быстрый способ начать. В любой момент можно перейти к настройке вручную.", "firstEmpty.createTitle": "Создать готовую к использованию базу знаний", + "firstEmpty.noCreatePermission": "Нет доступных баз знаний.\nОбратитесь к сопровождающему ресурса или владельцу рабочего пространства, чтобы запросить доступ.", "firstEmpty.or": "Или", "firstEmpty.pipelineDescription": "Настройте собственный поток разбиения, очистки и индексации для специализированных данных.", "firstEmpty.pipelineTitle": "Создать пользовательскую базу знаний", diff --git a/web/i18n/sl-SI/dataset.json b/web/i18n/sl-SI/dataset.json index 3d15b17e1c8..8b1f0522dd3 100644 --- a/web/i18n/sl-SI/dataset.json +++ b/web/i18n/sl-SI/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "Ustvari bazo znanja, pripravljeno za uporabo", + "firstEmpty.noCreatePermission": "Ni dostopnih baz znanja.\nZa dostop se obrnite na vzdrževalca vira ali lastnika delovnega prostora.", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "Določite svoj potek razčlenjevanja, čiščenja in indeksiranja za specializirane podatke.", "firstEmpty.pipelineTitle": "Zgradi prilagojeno bazo znanja", diff --git a/web/i18n/th-TH/dataset.json b/web/i18n/th-TH/dataset.json index f9aa463c538..ee6c6d41283 100644 --- a/web/i18n/th-TH/dataset.json +++ b/web/i18n/th-TH/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Already have a knowledge base? Connect it via API without migrating data.", "firstEmpty.createDescription": "The fastest way to start. Switch to custom anytime.", "firstEmpty.createTitle": "สร้างฐานความรู้ที่พร้อมใช้งาน", + "firstEmpty.noCreatePermission": "ไม่มีฐานความรู้ที่เข้าถึงได้\nโปรดติดต่อผู้ดูแลทรัพยากรหรือเจ้าของเวิร์กสเปซเพื่อขอสิทธิ์เข้าถึง", "firstEmpty.or": "Or", "firstEmpty.pipelineDescription": "กำหนดโฟลว์การแบ่งส่วน การล้างข้อมูล และการจัดทำดัชนีของคุณเองสำหรับข้อมูลเฉพาะทาง", "firstEmpty.pipelineTitle": "สร้างฐานความรู้แบบกำหนดเอง", diff --git a/web/i18n/tr-TR/dataset.json b/web/i18n/tr-TR/dataset.json index 8a298e8d0e9..6f74525b48d 100644 --- a/web/i18n/tr-TR/dataset.json +++ b/web/i18n/tr-TR/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Zaten bir bilgi tabanınız var mı? Verileri taşımadan API ile bağlayın.", "firstEmpty.createDescription": "Başlamanın en hızlı yolu. İstediğiniz zaman özele geçebilirsiniz.", "firstEmpty.createTitle": "Kullanıma hazır bilgi tabanı oluştur", + "firstEmpty.noCreatePermission": "Erişilebilir bilgi tabanı yok.\nErişim istemek için kaynak bakımcısı veya çalışma alanı sahibiyle iletişime geçin.", "firstEmpty.or": "Veya", "firstEmpty.pipelineDescription": "Özel veriler için kendi parçalama, temizleme ve indeksleme akışınızı tanımlayın.", "firstEmpty.pipelineTitle": "Özel bilgi tabanı oluştur", diff --git a/web/i18n/uk-UA/dataset.json b/web/i18n/uk-UA/dataset.json index 26e19511f55..980247db8c3 100644 --- a/web/i18n/uk-UA/dataset.json +++ b/web/i18n/uk-UA/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Уже маєте базу знань? Підключіть її через API без міграції даних.", "firstEmpty.createDescription": "Найшвидший спосіб почати. У будь-який момент можна перейти до власного налаштування.", "firstEmpty.createTitle": "Створити готову до використання базу знань", + "firstEmpty.noCreatePermission": "Немає доступних баз знань.\nЗверніться до супроводжувача ресурсу або власника робочого простору, щоб отримати доступ.", "firstEmpty.or": "Або", "firstEmpty.pipelineDescription": "Визначте власний процес поділу, очищення та індексації для спеціалізованих даних.", "firstEmpty.pipelineTitle": "Створити власну базу знань", diff --git a/web/i18n/vi-VN/dataset.json b/web/i18n/vi-VN/dataset.json index 1f54bf9d74b..65eb32146c4 100644 --- a/web/i18n/vi-VN/dataset.json +++ b/web/i18n/vi-VN/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "Đã có cơ sở tri thức? Kết nối qua API mà không cần di chuyển dữ liệu.", "firstEmpty.createDescription": "Cách nhanh nhất để bắt đầu. Bạn có thể chuyển sang tùy chỉnh bất cứ lúc nào.", "firstEmpty.createTitle": "Tạo cơ sở tri thức dùng ngay", + "firstEmpty.noCreatePermission": "Không có cơ sở tri thức nào có thể truy cập.\nHãy liên hệ người bảo trì tài nguyên hoặc Chủ sở hữu workspace để yêu cầu quyền truy cập.", "firstEmpty.or": "Hoặc", "firstEmpty.pipelineDescription": "Tự định nghĩa luồng chia đoạn, làm sạch và lập chỉ mục cho dữ liệu chuyên biệt.", "firstEmpty.pipelineTitle": "Xây dựng cơ sở tri thức tùy chỉnh", diff --git a/web/i18n/zh-Hans/dataset.json b/web/i18n/zh-Hans/dataset.json index 0d0ff0ec062..85f79325a95 100644 --- a/web/i18n/zh-Hans/dataset.json +++ b/web/i18n/zh-Hans/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "已有知识库?通过 API 直接接入,无需迁移数据。", "firstEmpty.createDescription": "上传文档,剩下的交给 Dify。最快的上手方式。", "firstEmpty.createTitle": "创建即用型知识库", + "firstEmpty.noCreatePermission": "暂无可访问的知识库。\n请联系资源维护者或工作区所有者获取权限。", "firstEmpty.or": "或", "firstEmpty.pipelineDescription": "用灵活的节点和步骤搭建自定义的数据处理工作流。", "firstEmpty.pipelineTitle": "构建自定义知识库", diff --git a/web/i18n/zh-Hant/dataset.json b/web/i18n/zh-Hant/dataset.json index ab9a3e12ddd..d1d9aa1a532 100644 --- a/web/i18n/zh-Hant/dataset.json +++ b/web/i18n/zh-Hant/dataset.json @@ -75,6 +75,7 @@ "firstEmpty.connectDescription": "已有知識庫?透過 API 直接連接,無需遷移資料。", "firstEmpty.createDescription": "最快的開始方式。之後可隨時切換為自訂。", "firstEmpty.createTitle": "建立即用型知識庫", + "firstEmpty.noCreatePermission": "暫無可存取的知識庫。\n請聯絡資源維護者或工作空間擁有者以取得權限。", "firstEmpty.or": "或", "firstEmpty.pipelineDescription": "為專用資料定義自己的分段、清理與索引流程。", "firstEmpty.pipelineTitle": "建立自訂知識庫", From 3abbab479873eda5e70ac34ed82ee8421f712d9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:05:40 +0800 Subject: [PATCH 12/63] chore: bump pyasn1 from 0.6.3 to 0.6.4 in /api (#39390) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index eeaee9224fc..10c48a52f94 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -5119,11 +5119,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] From 389a7608bf3b302a1a57273241cf203bd1c9daf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:05:53 +0800 Subject: [PATCH 13/63] chore: bump gitpython from 3.1.50 to 3.1.52 in /api (#39389) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 10c48a52f94..125cfca452b 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -2710,14 +2710,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.50" +version = "3.1.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/df0bafa4eb5ea2f51e1adee9f7a94c8e62c5d180e65117045dfca3439c8a/gitpython-3.1.52.tar.gz", hash = "sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e", size = 223726, upload-time = "2026-07-16T03:15:59.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, + { url = "https://files.pythonhosted.org/packages/8d/90/04dff7c1e176bb1c3011ef1647393d368790da710d8dde1cdcfad301f45a/gitpython-3.1.52-py3-none-any.whl", hash = "sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a", size = 215366, upload-time = "2026-07-16T03:15:58.239Z" }, ] [[package]] From bc21972878782c7c7d5daa047b01aeb06446ca65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:06:08 +0800 Subject: [PATCH 14/63] chore: bump pyasn1 from 0.6.3 to 0.6.4 in /dify-agent (#39388) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 1ec1438ce51..21a2a36c76c 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -2505,11 +2505,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] From d2aea76dec8a0e330ca2c6cbcce65d4c8b2025b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:06:30 +0800 Subject: [PATCH 15/63] chore: bump setuptools from 82.0.1 to 83.0.0 in /dify-agent (#39387) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 840aca84d1a..a496d19cdd1 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -71,5 +71,5 @@ docs = [ ] [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=83.0.0"] build-backend = "setuptools.build_meta" From c0b991c5f92c79e267ab2d98ec4d8da7460a00b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:06:51 +0800 Subject: [PATCH 16/63] chore: bump google.golang.org/grpc from 1.82.0 to 1.82.1 in /dify-agent-runtime (#39386) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent-runtime/go.mod | 2 +- dify-agent-runtime/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dify-agent-runtime/go.mod b/dify-agent-runtime/go.mod index 9cf551446c4..297b144dcd1 100644 --- a/dify-agent-runtime/go.mod +++ b/dify-agent-runtime/go.mod @@ -5,7 +5,7 @@ go 1.26 require ( github.com/landlock-lsm/go-landlock v0.9.0 github.com/spf13/cobra v1.10.2 - google.golang.org/grpc v1.82.0 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.37.1 ) diff --git a/dify-agent-runtime/go.sum b/dify-agent-runtime/go.sum index 15705eabb41..b4fbc8b3322 100644 --- a/dify-agent-runtime/go.sum +++ b/dify-agent-runtime/go.sum @@ -62,8 +62,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 66afa3bf4afd8cb69e08304d04f49c1c566f7717 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:16:21 +0900 Subject: [PATCH 17/63] test: use sqlite3 session in test_clear_free_plan_tenant_expired_logs (#38693) --- ...est_clear_free_plan_tenant_expired_logs.py | 985 ++++++++++-------- 1 file changed, 539 insertions(+), 446 deletions(-) diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index 9be5af2b046..863d0b3aef6 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -1,335 +1,451 @@ import datetime +import json import logging +from collections.abc import Callable +from decimal import Decimal from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock import pytest +from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from enums.cloud_plan import CloudPlan +from graphon.file import FileTransferMethod, FileType +from models.account import Tenant +from models.enums import ( + ConversationFromSource, + CreatorUserRole, + FeedbackFromSource, + FeedbackRating, + MessageChainType, +) +from models.model import ( + App, + AppAnnotationHitHistory, + AppMode, + Conversation, + Message, + MessageAgentThought, + MessageAnnotation, + MessageChain, + MessageFeedback, + MessageFile, +) +from models.web import SavedMessage +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom from services import clear_free_plan_tenant_expired_logs as service_module from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs +REAL_DATETIME = datetime.datetime +SQLITE_MODELS = ( + Tenant, + App, + Conversation, + Message, + MessageFeedback, + MessageFile, + MessageAnnotation, + MessageChain, + MessageAgentThought, + AppAnnotationHitHistory, + SavedMessage, + WorkflowAppLog, +) + +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), +] + + +def _create_tenant( + tenant_id: str, + *, + created_at: datetime.datetime | None = None, +) -> Tenant: + """Create a tenant with a stable ID and optional batch-selection timestamp.""" + tenant = Tenant(name=f"Tenant {tenant_id}") + tenant.id = tenant_id + if created_at is not None: + tenant.created_at = created_at + return tenant + + +def _create_app(app_id: str, tenant_id: str) -> App: + """Create a persisted app used to scope cleanup queries by tenant.""" + return App( + id=app_id, + tenant_id=tenant_id, + name=f"App {app_id}", + description="", + mode=AppMode.CHAT, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) + + +def _create_conversation( + conversation_id: str, + app_id: str, + *, + updated_at: datetime.datetime, +) -> Conversation: + """Create a conversation with the fields required by backup serialization.""" + conversation = Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.CHAT, + name=f"Conversation {conversation_id}", + status="normal", + from_source=ConversationFromSource.API, + from_end_user_id="end-user-1", + ) + conversation._inputs = {} + conversation.updated_at = updated_at + return conversation + + +def _create_message( + message_id: str, + app_id: str, + conversation_id: str, + *, + created_at: datetime.datetime, +) -> Message: + """Create a message with the fields required by backup serialization.""" + message = Message( + id=message_id, + app_id=app_id, + conversation_id=conversation_id, + query="question", + message={"role": "user", "content": "question"}, + answer="answer", + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0002"), + currency="USD", + from_source=ConversationFromSource.API, + ) + message._inputs = {} + message.created_at = created_at + message.updated_at = created_at + return message + + +def _create_workflow_app_log( + log_id: str, + tenant_id: str, + app_id: str, + *, + created_at: datetime.datetime, +) -> WorkflowAppLog: + """Create a workflow app log eligible for retention cleanup.""" + log = WorkflowAppLog( + tenant_id=tenant_id, + app_id=app_id, + workflow_id="workflow-1", + workflow_run_id=f"run-{log_id}", + created_from=WorkflowAppLogCreatedFrom.SERVICE_API, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + ) + log.id = log_id + log.created_at = created_at + return log + + +def _create_related_records(message_id: str) -> list[object]: + """Create one real row for every message-related table cleaned by the service.""" + return [ + MessageFeedback( + app_id="app-1", + conversation_id="conversation-1", + message_id=message_id, + rating=FeedbackRating.LIKE, + from_source=FeedbackFromSource.USER, + ), + MessageFile( + message_id=message_id, + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + ), + MessageAnnotation( + app_id="app-1", + question="question", + content="answer", + account_id="account-1", + message_id=message_id, + ), + MessageChain(message_id=message_id, type=MessageChainType.SYSTEM, input="input", output="output"), + MessageAgentThought( + message_id=message_id, + position=1, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + tool_labels_str="{}", + tool_meta_str="{}", + ), + AppAnnotationHitHistory( + app_id="app-1", + annotation_id="annotation-1", + source="annotation", + question="question", + account_id="account-1", + score=1.0, + message_id=message_id, + annotation_question="question", + annotation_content="answer", + ), + SavedMessage( + app_id="app-1", + message_id=message_id, + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + ), + ] + class TestClearFreePlanTenantExpiredLogs: - """Unit tests for ClearFreePlanTenantExpiredLogs._clear_message_related_tables method.""" + """Exercise message-related cleanup through a caller-owned SQLite transaction.""" - @pytest.fixture - def mock_session(self): - """Create a mock database session.""" - session = Mock(spec=Session) - session.scalars.return_value.all.return_value = [] - return session + def test_empty_message_ids_returns_without_touching_persisted_rows( + self, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + record = MessageChain(message_id="msg-1", type=MessageChainType.SYSTEM, input="input", output="output") + sqlite_session.add(record) + sqlite_session.commit() + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) - @pytest.fixture - def mock_storage(self): - """Create a mock storage object.""" - storage = Mock() - storage.save.return_value = None - return storage + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", []) - @pytest.fixture - def sample_message_ids(self): - """Sample message IDs for testing.""" - return ["msg-1", "msg-2", "msg-3"] + assert sqlite_session.get(MessageChain, record.id) is not None + storage.save.assert_not_called() - @pytest.fixture - def sample_records(self): - """Sample records for testing.""" - records = [] - for i in range(3): - record = Mock() - record.id = f"record-{i}" - record.to_dict.return_value = { - "id": f"record-{i}", - "message_id": f"msg-{i}", - "created_at": datetime.datetime.now().isoformat(), - } - records.append(record) - return records + def test_no_related_records_skips_backup( + self, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) - def test_clear_message_related_tables_empty_message_ids(self, mock_session): - """Test that method returns early when message_ids is empty.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", []) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["missing-message"]) - # Should not call any database operations - mock_session.scalars.assert_not_called() - mock_storage.save.assert_not_called() + storage.save.assert_not_called() - def test_clear_message_related_tables_no_records_found(self, mock_session, sample_message_ids): - """Test when no related records are found.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = [] + def test_related_records_are_backed_up_and_deleted( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + records = _create_related_records("msg-1") + sqlite_session.add_all(records) + sqlite_session.commit() + record_keys = [(type(record), record.id) for record in records] + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - # Should call scalars for each related table but find no records - assert mock_session.scalars.call_count > 0 - mock_storage.save.assert_not_called() + assert storage.save.call_count == len(records) + backed_up_payloads = [json.loads(call.args[1]) for call in storage.save.call_args_list] + assert all(payload for payload in backed_up_payloads) + with Session(sqlite_engine) as verification_session: + assert all(verification_session.get(model, record_id) is None for model, record_id in record_keys) - def test_clear_message_related_tables_with_records_and_to_dict( - self, mock_session, sample_message_ids, sample_records - ): - """Test when records are found and have to_dict method.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = sample_records + def test_storage_failure_still_deletes_records( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + record = MessageChain(message_id="msg-1", type=MessageChainType.SYSTEM, input="input", output="output") + sqlite_session.add(record) + sqlite_session.commit() + storage = MagicMock() + storage.save.side_effect = RuntimeError("storage error") + monkeypatch.setattr(service_module, "storage", storage) - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - # Should call to_dict on each record (called once per table, so 7 times total) - for record in sample_records: - assert record.to_dict.call_count == 7 + with Session(sqlite_engine) as verification_session: + assert verification_session.get(MessageChain, record.id) is None - # Should save backup data - assert mock_storage.save.call_count > 0 + def test_serialization_failure_skips_backup_but_deletes_records( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + record = SavedMessage( + app_id="app-1", + message_id="msg-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + ) + sqlite_session.add(record) + sqlite_session.commit() + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) + monkeypatch.setattr( + ClearFreePlanTenantExpiredLogs, + "_serialize_record", + MagicMock(side_effect=RuntimeError("serialization error")), + ) - def test_clear_message_related_tables_with_records_no_to_dict(self, mock_session, sample_message_ids): - """Test when records are found but don't have to_dict method.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - # Create records without to_dict method - records = [] - for i in range(2): - record = Mock() - mock_table = Mock() - mock_id_column = Mock() - mock_id_column.name = "id" - mock_message_id_column = Mock() - mock_message_id_column.name = "message_id" - mock_table.columns = [mock_id_column, mock_message_id_column] - record.__table__ = mock_table - record.id = f"record-{i}" - record.message_id = f"msg-{i}" - del record.to_dict - records.append(record) + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - # Mock records for first table only, empty for others - mock_session.scalars.return_value.all.side_effect = [ - records, - [], - [], - [], - [], - [], - [], - ] + storage.save.assert_not_called() + with Session(sqlite_engine) as verification_session: + assert verification_session.get(SavedMessage, record.id) is None - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) + def test_deletion_is_scoped_to_requested_message_ids( + self, + sqlite_session: Session, + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + target = MessageChain(message_id="msg-1", type=MessageChainType.SYSTEM, input="input", output="output") + retained = MessageChain(message_id="msg-2", type=MessageChainType.SYSTEM, input="input", output="output") + sqlite_session.add_all([target, retained]) + sqlite_session.commit() + monkeypatch.setattr(service_module, "storage", MagicMock()) - # Should save backup data even without to_dict - assert mock_storage.save.call_count > 0 + ClearFreePlanTenantExpiredLogs._clear_message_related_tables(sqlite_session, "tenant-123", ["msg-1"]) + sqlite_session.commit() - def test_clear_message_related_tables_storage_error_continues( - self, mock_session, sample_message_ids, sample_records - ): - """Test that method continues even when storage.save fails.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_storage.save.side_effect = Exception("Storage error") - - mock_session.scalars.return_value.all.return_value = sample_records - - # Should not raise exception - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - # Should still delete records even if backup fails - assert mock_session.execute.called - - def test_clear_message_related_tables_serialization_error_continues(self, mock_session, sample_message_ids): - """Test that method continues even when record serialization fails.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - record = Mock() - record.id = "record-1" - record.to_dict.side_effect = Exception("Serialization error") - - mock_session.scalars.return_value.all.return_value = [record] - - # Should not raise exception - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - # Should still delete records even if serialization fails - assert mock_session.execute.called - - def test_clear_message_related_tables_deletion_called(self, mock_session, sample_message_ids, sample_records): - """Test that deletion is called for found records.""" - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = sample_records - - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - # Should call execute(delete(...)) for each table that has records - assert mock_session.execute.called - - def test_clear_message_related_tables_all_serialization_fails_skips_backup_but_deletes( - self, mock_session, sample_message_ids - ): - record = Mock() - record.id = "record-1" - record.to_dict.side_effect = Exception("Serialization error") - - with patch("services.clear_free_plan_tenant_expired_logs.storage") as mock_storage: - mock_session.scalars.return_value.all.return_value = [record] - - ClearFreePlanTenantExpiredLogs._clear_message_related_tables(mock_session, "tenant-123", sample_message_ids) - - mock_storage.save.assert_not_called() - assert mock_session.execute.called + with Session(sqlite_engine) as verification_session: + assert verification_session.get(MessageChain, target.id) is None + assert verification_session.get(MessageChain, retained.id) is not None class _ImmediateFuture: - def __init__(self, fn, args, kwargs): + """Run submitted test work synchronously while preserving the Future interface.""" + + def __init__(self, fn: Callable[..., object], args: tuple[object, ...], kwargs: dict[str, object]) -> None: self._fn = fn self._args = args self._kwargs = kwargs - def result(self): + def result(self) -> object: return self._fn(*self._args, **self._kwargs) class _ImmediateExecutor: - def __init__(self, *args, **kwargs) -> None: - self.submitted: list[tuple[object, tuple[object, ...], dict[str, object]]] = [] + """Deterministic ThreadPoolExecutor replacement for orchestration tests.""" - def submit(self, fn, *args, **kwargs): + def __init__(self, *args: object, **kwargs: object) -> None: + self.submitted: list[tuple[Callable[..., object], tuple[object, ...], dict[str, object]]] = [] + + def submit(self, fn: Callable[..., object], *args: object, **kwargs: object) -> _ImmediateFuture: self.submitted.append((fn, args, kwargs)) return _ImmediateFuture(fn, args, kwargs) -def _session_wrapper_for_no_autoflush(session: Mock) -> Mock: - """ - Return an object with a no_autoflush context manager for legacy tests that need Session-like wrappers. - """ - cm = MagicMock() - cm.__enter__.return_value = session - cm.__exit__.return_value = None - - wrapper = MagicMock() - wrapper.no_autoflush = cm - return wrapper - - -def _sessionmaker_wrapper_for_begin(session: Mock) -> Mock: - """ - ClearFreePlanTenantExpiredLogs.process uses: with sessionmaker(db.engine).begin() as session: - so sessionmaker(db.engine) must return an object with a begin() method that returns a context manager. - """ - begin_cm = MagicMock() - begin_cm.__enter__.return_value = session - begin_cm.__exit__.return_value = None - - sessionmaker_result = MagicMock() - sessionmaker_result.begin.return_value = begin_cm - return sessionmaker_result - - -def _session_wrapper_for_direct(session: Mock) -> Mock: - """Return an object usable as a direct context manager for legacy Session-like test paths.""" - wrapper = MagicMock() - wrapper.__enter__.return_value = session - wrapper.__exit__.return_value = None - return wrapper - - -def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) -> None: +def _configure_process_boundaries(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> _ImmediateExecutor: + """Bind service-owned sessions to SQLite and make thread scheduling deterministic.""" + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) flask_app = service_module.Flask("test-app") + monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) + executor = _ImmediateExecutor() + monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) + monkeypatch.setattr(service_module.click, "style", lambda message, **_kwargs: message) + return executor - app_session = MagicMock() - app_session.scalars.return_value.all.return_value = [SimpleNamespace(id="app-1"), SimpleNamespace(id="app-2")] - monkeypatch.setattr( - service_module, - "db", - SimpleNamespace(engine=object()), +def test_process_tenant_processes_and_persists_all_batches( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + flask_app = service_module.Flask("test-app") + old = REAL_DATETIME.now() - datetime.timedelta(days=30) + recent = REAL_DATETIME.now() + sqlite_session.add_all( + [ + _create_app("app-1", "tenant-1"), + _create_app("app-2", "tenant-2"), + _create_conversation("conversation-old", "app-1", updated_at=old), + _create_conversation("conversation-recent", "app-1", updated_at=recent), + _create_conversation("conversation-other", "app-2", updated_at=old), + _create_message("message-old", "app-1", "conversation-old", created_at=old), + _create_message("message-recent", "app-1", "conversation-recent", created_at=recent), + _create_message("message-other", "app-2", "conversation-other", created_at=old), + _create_workflow_app_log("log-old", "tenant-1", "app-1", created_at=old), + _create_workflow_app_log("log-recent", "tenant-1", "app-1", created_at=recent), + _create_workflow_app_log("log-other", "tenant-2", "app-2", created_at=old), + ] ) - - mock_storage = MagicMock() - monkeypatch.setattr(service_module, "storage", mock_storage) + sqlite_session.commit() + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) + storage = MagicMock() + monkeypatch.setattr(service_module, "storage", storage) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) - + monkeypatch.setattr(service_module.click, "style", lambda message, **_kwargs: message) clear_related = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "_clear_message_related_tables", clear_related) - # Session sequence for messages, conversations, workflow_app_logs loops: - # - messages: one batch then empty - # - conversations: one batch then empty - # - workflow app logs: one batch then empty - msg1 = SimpleNamespace(id="m1", to_dict=lambda: {"id": "m1"}) - conv1 = SimpleNamespace(id="c1", to_dict=lambda: {"id": "c1"}) - log1 = SimpleNamespace(id="l1", to_dict=lambda: {"id": "l1"}) - - msg_session_1 = MagicMock() - msg_session_1.scalars.return_value.all.return_value = [msg1] - - msg_session_2 = MagicMock() - msg_session_2.scalars.return_value.all.return_value = [] - - conv_session_1 = MagicMock() - conv_session_1.scalars.return_value.all.return_value = [conv1] - - conv_session_2 = MagicMock() - conv_session_2.scalars.return_value.all.return_value = [] - - wal_session_1 = MagicMock() - wal_session_1.scalars.return_value.all.return_value = [log1] - - wal_session_2 = MagicMock() - wal_session_2.scalars.return_value.all.return_value = [] - - session_wrappers = [ - _sessionmaker_wrapper_for_begin(msg_session_1), - _sessionmaker_wrapper_for_begin(msg_session_2), - _sessionmaker_wrapper_for_begin(conv_session_1), - _sessionmaker_wrapper_for_begin(conv_session_2), - _sessionmaker_wrapper_for_begin(wal_session_1), - _sessionmaker_wrapper_for_begin(wal_session_2), - ] - - def fake_sessionmaker(*args, **kwargs): - if kwargs.get("autoflush") is False: - return session_wrappers.pop(0) - return object() - - monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) - - def fake_select(*_args, **_kwargs): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(service_module, "select", fake_select) - - # Repositories for workflow node executions and workflow runs - node_execution = SimpleNamespace(id="ne-1") + node_execution = SimpleNamespace(id="node-execution-1") node_execution.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) - node_repo = MagicMock() node_repo.get_expired_executions_batch.side_effect = [[node_execution], []] node_repo.delete_executions_by_ids.return_value = 1 - run_repo = MagicMock() - run_repo.get_expired_runs_batch.side_effect = [[SimpleNamespace(id="wr-1", to_dict=lambda: {"id": "wr-1"})], []] + run_repo.get_expired_runs_batch.side_effect = [ + [SimpleNamespace(id="workflow-run-1", to_dict=lambda: {"id": "workflow-run-1"})], + [], + ] run_repo.delete_runs_by_ids.return_value = 1 monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", - lambda _sm: node_repo, + lambda _session_maker: node_repo, ) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", - lambda _sm: run_repo, + lambda _session_maker: run_repo, ) - ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=10, session=app_session) + ClearFreePlanTenantExpiredLogs.process_tenant( + flask_app, + "tenant-1", + days=7, + batch=1, + session=sqlite_session, + ) - # messages backup, conversations backup, node executions backup, runs backup, workflow app logs backup - app_session.scalars.assert_called_once() - assert mock_storage.save.call_count >= 5 - clear_related.assert_called() + assert clear_related.call_count == 1 + related_session, related_tenant_id, message_ids = clear_related.call_args.args + assert isinstance(related_session, Session) + assert related_tenant_id == "tenant-1" + assert message_ids == ["message-old"] + assert storage.save.call_count == 5 + with Session(sqlite_engine) as verification_session: + assert verification_session.get(Message, "message-old") is None + assert verification_session.get(Conversation, "conversation-old") is None + assert verification_session.get(WorkflowAppLog, "log-old") is None + assert verification_session.get(Message, "message-recent") is not None + assert verification_session.get(Message, "message-other") is not None + assert verification_session.get(Conversation, "conversation-recent") is not None + assert verification_session.get(Conversation, "conversation-other") is not None + assert verification_session.get(WorkflowAppLog, "log-recent") is not None + assert verification_session.get(WorkflowAppLog, "log-other") is not None def test_serialize_record_falls_back_to_table_columns() -> None: - record = SimpleNamespace(id="ne-1", node_id="node-1") + record = SimpleNamespace(id="node-execution-1", node_id="node-1") record.__table__ = SimpleNamespace( columns=[ SimpleNamespace(name="id"), @@ -338,263 +454,240 @@ def test_serialize_record_falls_back_to_table_columns() -> None: ) assert ClearFreePlanTenantExpiredLogs._serialize_record(record) == { - "id": "ne-1", + "id": "node-execution-1", "node_id": "node-1", } def test_process_with_tenant_ids_filters_by_plan_and_logs_errors( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + sqlite_session: Session, + sqlite_engine: Engine, ) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - - # Total tenant count query - count_session = MagicMock() - count_session.scalar.return_value = 2 - - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: _sessionmaker_wrapper_for_begin(count_session)) - - # Avoid LocalProxy usage - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) - - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) - - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) - echo_mock = MagicMock() - monkeypatch.setattr(service_module.click, "echo", echo_mock) - + sqlite_session.add_all( + [_create_tenant("tenant-sandbox"), _create_tenant("tenant-paid"), _create_tenant("tenant-fail")] + ) + sqlite_session.commit() + _configure_process_boundaries(monkeypatch, sqlite_engine) + monkeypatch.setattr(service_module.click, "echo", MagicMock()) monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", True) - def fake_get_info(tenant_id: str): - if tenant_id == "t_sandbox": + def fake_get_info(tenant_id: str) -> dict[str, dict[str, str]]: + if tenant_id == "tenant-sandbox": return {"subscription": {"plan": CloudPlan.SANDBOX}} - if tenant_id == "t_fail": - raise RuntimeError("boom") + if tenant_id == "tenant-fail": + raise RuntimeError("billing failure") return {"subscription": {"plan": "team"}} monkeypatch.setattr(service_module.BillingService, "get_info", staticmethod(fake_get_info)) - - process_tenant_mock = MagicMock(side_effect=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("err"))) - monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) + process_tenant = MagicMock(side_effect=RuntimeError("cleanup failure")) + monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) with caplog.at_level(logging.ERROR, logger=service_module.logger.name): - ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=["t_sandbox", "t_paid", "t_fail"]) + ClearFreePlanTenantExpiredLogs.process( + days=7, + batch=10, + tenant_ids=["tenant-sandbox", "tenant-paid", "tenant-fail"], + ) - # Only sandbox tenant should attempt processing, and its failure should be swallowed + logged. - assert process_tenant_mock.call_count == 1 - assert process_tenant_mock.call_args.args[4] is count_session - assert "Failed to process tenant t_sandbox" in caplog.messages - assert "Failed to process tenant t_fail" in caplog.messages + assert process_tenant.call_count == 1 + owned_session = process_tenant.call_args.args[4] + assert isinstance(owned_session, Session) + assert owned_session.get_bind() is sqlite_engine + assert "Failed to process tenant tenant-sandbox" in caplog.messages + assert "Failed to process tenant tenant-fail" in caplog.messages -def test_process_without_tenant_ids_batches_and_scales_interval(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) - - started_at = datetime.datetime(2023, 4, 3, 8, 59, 24) +def test_process_without_tenant_ids_batches_and_scales_interval( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + started_at = REAL_DATETIME(2023, 4, 3, 8, 59, 24) fixed_now = started_at + datetime.timedelta(hours=2) + selected_tenants = [ + _create_tenant("tenant-a", created_at=started_at + datetime.timedelta(minutes=30)), + _create_tenant("tenant-b", created_at=started_at + datetime.timedelta(hours=1)), + ] + future_tenants = [ + _create_tenant(f"future-{index}", created_at=started_at + datetime.timedelta(hours=4)) for index in range(100) + ] + sqlite_session.add_all([*selected_tenants, *future_tenants]) + sqlite_session.commit() - class FixedDateTime(datetime.datetime): + class FixedDateTime(REAL_DATETIME): @classmethod - def now(cls, tz=None): + def now(cls, tz: datetime.tzinfo | None = None) -> REAL_DATETIME: return fixed_now monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) - - # Avoid LocalProxy usage - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) - - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) - - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) + _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - - # Sessions used: - # 1) total tenant count - # 2) per-batch tenant scan (interval counts + tenant list) - total_session = MagicMock() - total_session.scalar.return_value = 250 - - rows = [SimpleNamespace(id="tenant-a"), SimpleNamespace(id="tenant-b")] - batch_session = MagicMock() - # 4 test intervals queried: 200, 200, 200, 50 — breaks on 50 <= 100 (4th interval = 3h) - batch_session.scalar.side_effect = [200, 200, 200, 50] - batch_session.execute.return_value = rows - - tenant_session_a = MagicMock() - tenant_session_b = MagicMock() - sessions = [ - _sessionmaker_wrapper_for_begin(total_session), - _sessionmaker_wrapper_for_begin(batch_session), - _sessionmaker_wrapper_for_begin(tenant_session_a), - _sessionmaker_wrapper_for_begin(tenant_session_b), - ] - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0)) - - process_tenant_mock = MagicMock() - monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) - - ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) - - # Should submit/process tenants from the batch query - assert process_tenant_mock.call_count == 2 - - -def test_process_with_tenant_ids_emits_progress_every_100(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - - count_session = MagicMock() - count_session.scalar.return_value = 100 - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: _sessionmaker_wrapper_for_begin(count_session)) - - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + process_tenant = MagicMock() + monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) + statements: list[str] = [] - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) + def record_statement( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + statements.append(statement) - echo_mock = MagicMock() - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) - monkeypatch.setattr(service_module.click, "echo", echo_mock) + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: + ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) + assert {call.args[1] for call in process_tenant.call_args_list} == {"tenant-a", "tenant-b"} + interval_counts = [ + statement + for statement in statements + if "count(tenants.id)" in statement.lower() and "between" in statement.lower() + ] + assert len(interval_counts) == 4 + assert all(isinstance(call.args[4], Session) for call in process_tenant.call_args_list) + + +def test_process_with_tenant_ids_emits_progress_every_100( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + tenant_ids = [f"tenant-{index}" for index in range(100)] + sqlite_session.add_all([_create_tenant(tenant_id) for tenant_id in tenant_ids]) + sqlite_session.commit() + _configure_process_boundaries(monkeypatch, sqlite_engine) + monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + echo = MagicMock() + monkeypatch.setattr(service_module.click, "echo", echo) monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", MagicMock()) - tenant_ids = [f"t{i}" for i in range(100)] ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=tenant_ids) - assert any("Processed 100 tenants" in str(call.args[0]) for call in echo_mock.call_args_list) + assert any("Processed 100 tenants" in str(call.args[0]) for call in echo.call_args_list) -def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) - - started_at = datetime.datetime(2023, 4, 3, 8, 59, 24) - # Keep the total range smaller than the minimum interval (1 hour) so the loop runs once. +def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: + started_at = REAL_DATETIME(2023, 4, 3, 8, 59, 24) fixed_now = started_at + datetime.timedelta(minutes=30) + sqlite_session.add(_create_tenant("tenant-in-range", created_at=started_at + datetime.timedelta(minutes=15))) + sqlite_session.add_all( + [ + _create_tenant(f"later-{index}", created_at=started_at + datetime.timedelta(minutes=45)) + for index in range(100) + ] + ) + sqlite_session.commit() - class FixedDateTime(datetime.datetime): + class FixedDateTime(REAL_DATETIME): @classmethod - def now(cls, tz=None): + def now(cls, tz: datetime.tzinfo | None = None) -> REAL_DATETIME: return fixed_now monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) - - flask_app = service_module.Flask("test-app") - monkeypatch.setattr(service_module, "current_app", SimpleNamespace(_get_current_object=lambda: flask_app)) - - executor = _ImmediateExecutor() - monkeypatch.setattr(service_module, "ThreadPoolExecutor", lambda **_kwargs: executor) - - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) + _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + process_tenant = MagicMock() + monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) + statements: list[str] = [] - total_session = MagicMock() - total_session.scalar.return_value = 250 + def record_statement( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + statements.append(statement) - rows = [SimpleNamespace(id="tenant-a")] - batch_session = MagicMock() - # All 5 intervals have > 100 tenants => for-else falls through to min interval (1h) - batch_session.scalar.side_effect = [200, 200, 200, 200, 200] - batch_session.execute.return_value = rows + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: + ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) - tenant_session = MagicMock() - sessions = [ - _sessionmaker_wrapper_for_begin(total_session), - _sessionmaker_wrapper_for_begin(batch_session), - _sessionmaker_wrapper_for_begin(tenant_session), + assert [call.args[1] for call in process_tenant.call_args_list] == ["tenant-in-range"] + interval_counts = [ + statement + for statement in statements + if "count(tenants.id)" in statement.lower() and "between" in statement.lower() ] - monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0)) - - process_tenant_mock = MagicMock() - monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock) - - ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=[]) - - assert process_tenant_mock.call_count == 1 - assert batch_session.scalar.call_count == 5 + assert len(interval_counts) == 5 -def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pytest.MonkeyPatch) -> None: +def test_process_tenant_repo_loops_break_on_empty_second_batch( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, +) -> None: flask_app = service_module.Flask("test-app") - - app_session = MagicMock() - app_session.scalars.return_value.all.return_value = [SimpleNamespace(id="app-1")] - - monkeypatch.setattr( - service_module, - "db", - SimpleNamespace(engine=object()), - ) - mock_storage = MagicMock() - monkeypatch.setattr(service_module, "storage", mock_storage) + sqlite_session.add(_create_app("app-1", "tenant-1")) + sqlite_session.commit() + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) + monkeypatch.setattr(service_module, "storage", MagicMock()) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.click, "style", lambda msg, **_kwargs: msg) + monkeypatch.setattr(service_module.click, "style", lambda message, **_kwargs: message) monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "_clear_message_related_tables", MagicMock()) - # Make message/conversation/workflow_app_log loops no-op (empty immediately) - empty_session = MagicMock() - empty_session.scalars.return_value.all.return_value = [] - session_wrappers = [ - _sessionmaker_wrapper_for_begin(empty_session), - _sessionmaker_wrapper_for_begin(empty_session), - _sessionmaker_wrapper_for_begin(empty_session), - ] - - def fake_sessionmaker(*args, **kwargs): - if kwargs.get("autoflush") is False: - return session_wrappers.pop(0) - return object() - - monkeypatch.setattr(service_module, "sessionmaker", fake_sessionmaker) - - def fake_select(*_args, **_kwargs): - stmt = MagicMock() - stmt.where.return_value = stmt - return stmt - - monkeypatch.setattr(service_module, "select", fake_select) - - # Repos: first returns exactly batch items -> no "< batch" break, second returns [] -> hit the len==0 break. - node_execution_1 = SimpleNamespace(id="ne-1") - node_execution_1.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) - node_execution_2 = SimpleNamespace(id="ne-2") - node_execution_2.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) - + node_executions = [SimpleNamespace(id="node-1"), SimpleNamespace(id="node-2")] + for node_execution in node_executions: + node_execution.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) node_repo = MagicMock() - node_repo.get_expired_executions_batch.side_effect = [ - [node_execution_1, node_execution_2], - [], - ] + node_repo.get_expired_executions_batch.side_effect = [node_executions, []] node_repo.delete_executions_by_ids.return_value = 2 - run_repo = MagicMock() run_repo.get_expired_runs_batch.side_effect = [ [ - SimpleNamespace(id="wr-1", to_dict=lambda: {"id": "wr-1"}), - SimpleNamespace(id="wr-2", to_dict=lambda: {"id": "wr-2"}), + SimpleNamespace(id="run-1", to_dict=lambda: {"id": "run-1"}), + SimpleNamespace(id="run-2", to_dict=lambda: {"id": "run-2"}), ], [], ] run_repo.delete_runs_by_ids.return_value = 2 + node_session_makers: list[object] = [] + run_session_makers: list[object] = [] + + def create_node_repo(session_maker: object) -> MagicMock: + node_session_makers.append(session_maker) + return node_repo + + def create_run_repo(session_maker: object) -> MagicMock: + run_session_makers.append(session_maker) + return run_repo + monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_node_execution_repository", - lambda _sm: node_repo, + create_node_repo, ) monkeypatch.setattr( service_module.DifyAPIRepositoryFactory, "create_api_workflow_run_repository", - lambda _sm: run_repo, + create_run_repo, ) - ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=2, session=app_session) + ClearFreePlanTenantExpiredLogs.process_tenant( + flask_app, + "tenant-1", + days=7, + batch=2, + session=sqlite_session, + ) - app_session.scalars.assert_called_once() assert node_repo.get_expired_executions_batch.call_count == 2 assert run_repo.get_expired_runs_batch.call_count == 2 + assert node_session_makers[0].kw["bind"] is sqlite_engine + assert run_session_makers[0].kw["bind"] is sqlite_engine From 0aa04f610e8e753d641962fa95ae165c8757368c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:23:00 +0800 Subject: [PATCH 18/63] chore: bump pillow from 12.2.0 to 12.3.0 in /dify-agent (#39328) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent/uv.lock | 128 +++++++++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 63 deletions(-) diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 21a2a36c76c..c355ba99dca 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -2331,71 +2331,73 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] From a4c7261bf9b7cc1b3dfde052d691fffe3d9b0fcd Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:51:33 +0900 Subject: [PATCH 19/63] test: use sqlite3 session in test_workflow (#38686) --- .../service_api/app/test_workflow.py | 325 +++++++++--------- 1 file changed, 153 insertions(+), 172 deletions(-) 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 f381bd3fbc4..7975a935f93 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 @@ -16,20 +16,20 @@ Focus on: import json import sys import uuid -from dataclasses import dataclass, field from datetime import UTC, datetime from inspect import unwrap +from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch import pytest from flask import Flask -from sqlalchemy.orm import sessionmaker +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.workflow import ( AppQueueManager, - DifyAPIRepositoryFactory, GraphEngineManager, WorkflowAppLogApi, WorkflowLogQuery, @@ -44,6 +44,7 @@ from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpErr from core.app.entities.app_invoke_entities import InvokeFrom from enums.cloud_plan import CloudPlan from graphon.enums import WorkflowExecutionStatus +from models import Account from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType @@ -51,58 +52,18 @@ from services.app_generate_service import AppGenerateService from services.billing_service import BillingService from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError -from services.workflow_app_service import LogView, LogViewDetails, WorkflowAppService +from services.workflow_app_service import WorkflowAppService def _default_workflow_inputs() -> dict[str, object]: return {"input": "value"} -def _default_log_details() -> LogViewDetails: - return {"trigger_metadata": {"node": "answer", "latency": 1.25}} - - -class _DbSessionStub: - def get(self, *args: object, **kwargs: object) -> None: - return None - - -@dataclass -class _DbStub: - engine: object = field(default_factory=object) - session: _DbSessionStub = field(default_factory=_DbSessionStub) - - -@dataclass -class _WorkflowRunRepositoryStub: - run: WorkflowRun | None - - def get_workflow_run_by_id(self, *, tenant_id: str, app_id: str, run_id: str) -> WorkflowRun | None: - return self.run if tenant_id and app_id and run_id else None - - def get_workflow_run_by_id_without_tenant(self, *, run_id: str) -> WorkflowRun | None: - return self.run if run_id else None - - -class _BeginStub: - def __enter__(self) -> object: - return object() - - def __exit__(self, exc_type: object, exc: object, tb: object) -> bool: - return False - - -class _SessionMakerStub: - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - def begin(self) -> _BeginStub: - return _BeginStub() - - def _make_workflow_run( run_id: str = "run-1", *, + tenant_id: str = "tenant-1", + app_id: str = "app-1", workflow_id: str = "wf-1", inputs: dict[str, object] | None = None, outputs: dict[str, object] | None = None, @@ -111,8 +72,8 @@ def _make_workflow_run( ) -> WorkflowRun: return WorkflowRun( id=run_id, - tenant_id="tenant-1", - app_id="app-1", + tenant_id=tenant_id, + app_id=app_id, workflow_id=workflow_id, type=WorkflowType.WORKFLOW, triggered_from=WorkflowRunTriggeredFrom.APP_RUN, @@ -133,12 +94,17 @@ def _make_workflow_run( ) -def _make_workflow_app_log() -> WorkflowAppLog: +def _make_workflow_app_log( + *, + tenant_id: str = "tenant-1", + app_id: str = "app-1", + workflow_run_id: str = "log-run-1", +) -> WorkflowAppLog: log = WorkflowAppLog( - tenant_id="tenant-1", - app_id="app-1", + tenant_id=tenant_id, + app_id=app_id, workflow_id="wf-1", - workflow_run_id="log-run-1", + workflow_run_id=workflow_run_id, created_from=WorkflowAppLogCreatedFrom.SERVICE_API, created_by_role=CreatorUserRole.ACCOUNT, created_by="account-1", @@ -148,16 +114,6 @@ def _make_workflow_app_log() -> WorkflowAppLog: return log -def _make_workflow_log_page() -> dict[str, object]: - return { - "page": 1, - "limit": 20, - "total": 1, - "has_more": False, - "data": [LogView(_make_workflow_app_log(), _default_log_details())], - } - - def _make_app_model( *, app_id: str = "app-1", @@ -177,6 +133,43 @@ def _make_end_user(user_id: str = "end-user-1") -> EndUser: return end_user +def _bind_sqlite_database( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +) -> None: + """Bind controller- and model-owned database access to the test engine.""" + database = SimpleNamespace(engine=sqlite_engine, session=sqlite_session) + monkeypatch.setattr(sys.modules["controllers.service_api.app.workflow"], "db", database) + monkeypatch.setattr(sys.modules["models.workflow"], "db", database) + + +def _persist_workflow_log( + sqlite_session: Session, + *, + tenant_id: str, + app_id: str, +) -> None: + workflow_run_id = "log-run-1" + sqlite_session.add_all( + [ + _make_workflow_run( + run_id=workflow_run_id, + tenant_id=tenant_id, + app_id=app_id, + created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), + finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), + ), + _make_workflow_app_log( + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + ), + ] + ) + sqlite_session.commit() + + def _expected_workflow_log_pagination_payload() -> dict[str, object]: return { "page": 1, @@ -195,16 +188,16 @@ def _expected_workflow_log_pagination_payload() -> dict[str, object]: "elapsed_time": 0.1, "total_tokens": 10, "total_steps": 1, - "created_at": 1767229200, - "finished_at": 1767229202, + "created_at": int(datetime(2026, 1, 1, 1).timestamp()), + "finished_at": int(datetime(2026, 1, 1, 1, 0, 2).timestamp()), "exceptions_count": 0, }, - "details": {"trigger_metadata": {"node": "answer", "latency": 1.25}}, + "details": None, "created_from": "service-api", "created_by_role": "account", "created_by_account": None, "created_by_end_user": None, - "created_at": 1767229203, + "created_at": int(datetime(2026, 1, 1, 1, 0, 3).timestamp()), } ], } @@ -364,15 +357,15 @@ class TestWorkflowAppService: assert hasattr(WorkflowAppService, "get_paginate_workflow_app_logs") assert callable(WorkflowAppService.get_paginate_workflow_app_logs) - @patch.object(WorkflowAppService, "get_paginate_workflow_app_logs") - def test_get_paginate_workflow_app_logs_returns_pagination(self, mock_get_logs): - """Test get_paginate_workflow_app_logs returns paginated result.""" - pagination = _make_workflow_log_page() - mock_get_logs.return_value = pagination - + @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) + def test_get_paginate_workflow_app_logs_returns_pagination(self, sqlite_session: Session): + """Test pagination returns committed logs scoped to the requested app.""" + log = _make_workflow_app_log() + sqlite_session.add(log) + sqlite_session.commit() service = WorkflowAppService() result = service.get_paginate_workflow_app_logs( - session=Mock(), + session=sqlite_session, app_model=_make_app_model(), keyword=None, status=None, @@ -384,7 +377,11 @@ class TestWorkflowAppService: created_by_account=None, ) - assert result == pagination + assert result["page"] == 1 + assert result["limit"] == 20 + assert result["total"] == 1 + assert result["has_more"] is False + assert [item.id for item in result["data"]] == [log.id] class TestWorkflowExecutionStatus: @@ -409,8 +406,9 @@ class TestWorkflowExecutionStatus: class TestAppGenerateServiceWorkflow: """Test AppGenerateService workflow integration.""" + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_accepts_workflow_args(self, mock_generate: MagicMock): + def test_generate_accepts_workflow_args(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate accepts workflow-specific args.""" mock_generate.return_value = {"result": "success"} @@ -419,15 +417,17 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"inputs": {"key": "value"}, "workflow_id": "workflow_123"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=False, ) assert result == {"result": "success"} mock_generate.assert_called_once() + assert mock_generate.call_args.kwargs["session"] is sqlite_session + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_raises_workflow_not_found_error(self, mock_generate: MagicMock): + def test_generate_raises_workflow_not_found_error(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate raises WorkflowNotFoundError.""" mock_generate.side_effect = WorkflowNotFoundError("Workflow not found") @@ -437,12 +437,13 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"workflow_id": "invalid_id"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=False, ) + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_raises_is_draft_workflow_error(self, mock_generate: MagicMock): + def test_generate_raises_is_draft_workflow_error(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate raises IsDraftWorkflowError.""" mock_generate.side_effect = IsDraftWorkflowError("Workflow is draft") @@ -452,12 +453,13 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"workflow_id": "draft_workflow"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=False, ) + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @patch.object(AppGenerateService, "generate") - def test_generate_supports_streaming_mode(self, mock_generate: MagicMock): + def test_generate_supports_streaming_mode(self, mock_generate: MagicMock, sqlite_session: Session): """Test generate supports streaming response mode.""" mock_stream = Mock() mock_generate.return_value = mock_stream @@ -467,7 +469,7 @@ class TestAppGenerateServiceWorkflow: user=_make_end_user(), args={"inputs": {}, "response_mode": "streaming"}, invoke_from=InvokeFrom.SERVICE_API, - session=MagicMock(), + session=sqlite_session, streaming=True, ) @@ -499,19 +501,23 @@ class TestWorkflowRunRepository: assert hasattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository") - @patch("repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository") - def test_workflow_run_repository_get_by_id(self, mock_factory): - """Test workflow run repository get_workflow_run_by_id method.""" + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun,)], indirect=True) + def test_workflow_run_repository_get_by_id(self, sqlite_engine: Engine, sqlite_session: Session): + """Test repository lookup against committed tenant-scoped state.""" run = _make_workflow_run(run_id=str(uuid.uuid4())) - mock_factory.return_value = _WorkflowRunRepositoryStub(run=run) - + sqlite_session.add(run) + sqlite_session.commit() from repositories.factory import DifyAPIRepositoryFactory - repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(sessionmaker()) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository( + sessionmaker(bind=sqlite_engine, expire_on_commit=False) + ) - result = repo.get_workflow_run_by_id(tenant_id="tenant_123", app_id="app_456", run_id="run_789") + result = repo.get_workflow_run_by_id(tenant_id="tenant-1", app_id="app-1", run_id=run.id) - assert result == run + assert result is not None + assert result.id == run.id + assert repo.get_workflow_run_by_id(tenant_id="other-tenant", app_id="app-1", run_id=run.id) is None class TestWorkflowRunDetailApi: @@ -524,16 +530,17 @@ class TestWorkflowRunDetailApi: with pytest.raises(NotWorkflowAppError): handler(api, app_model=app_model, workflow_run_id="run") - def test_success(self, monkeypatch: pytest.MonkeyPatch) -> None: - run = _make_workflow_run(run_id="run") - repo = _WorkflowRunRepositoryStub(run=run) - workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module, "db", _DbStub()) - monkeypatch.setattr( - DifyAPIRepositoryFactory, - "create_api_workflow_run_repository", - lambda *_args, **_kwargs: repo, - ) + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun,)], indirect=True) + def test_success( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: + run = _make_workflow_run(run_id="run", tenant_id="t1", app_id="a1") + sqlite_session.add(run) + sqlite_session.commit() + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) api = WorkflowRunDetailApi() handler = unwrap(api.get) @@ -546,7 +553,8 @@ class TestWorkflowRunDetailApi: class TestWorkflowRunApi: - def test_not_workflow_app(self, app: Flask) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_not_workflow_app(self, app: Flask, sqlite_session: Session) -> None: api = WorkflowRunApi() handler = unwrap(api.post) app_model = _make_app_model(mode=AppMode.CHAT) @@ -554,9 +562,10 @@ class TestWorkflowRunApi: with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): with pytest.raises(NotWorkflowAppError): - handler(api, session=Mock(), app_model=app_model, end_user=end_user) + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) - def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -570,7 +579,7 @@ class TestWorkflowRunApi: with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): with pytest.raises(InvokeRateLimitHttpError): - handler(api, session=Mock(), app_model=app_model, end_user=end_user) + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) def test_sandbox_billing_does_not_gate_default_workflow_run( self, app: Flask, monkeypatch: pytest.MonkeyPatch @@ -680,7 +689,8 @@ class TestWorkflowRunByIdApi: else: billing_get_info.assert_not_called() - def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False) monkeypatch.setattr( @@ -696,9 +706,10 @@ class TestWorkflowRunByIdApi: with app.test_request_context("/workflows/1/run", method="POST", json={"inputs": {}}): with pytest.raises(NotFound): - handler(api, session=Mock(), app_model=app_model, end_user=end_user, workflow_id="w1") + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user, workflow_id="w1") - def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False) monkeypatch.setattr( @@ -714,7 +725,7 @@ class TestWorkflowRunByIdApi: with app.test_request_context("/workflows/1/run", method="POST", json={"inputs": {}}): with pytest.raises(BadRequest): - handler(api, session=Mock(), app_model=app_model, end_user=end_user, workflow_id="w1") + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user, workflow_id="w1") class TestWorkflowTaskStopApi: @@ -748,28 +759,16 @@ class TestWorkflowTaskStopApi: class TestWorkflowAppLogApi: - def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - workflow_module = sys.modules["controllers.service_api.app.workflow"] - workflow_model_module = sys.modules["models.workflow"] - monkeypatch.setattr(workflow_module, "db", _DbStub()) - monkeypatch.setattr(workflow_model_module, "db", _DbStub()) - monkeypatch.setattr(workflow_module, "sessionmaker", _SessionMakerStub) - monkeypatch.setattr( - WorkflowAppService, - "get_paginate_workflow_app_logs", - lambda *_args, **_kwargs: _make_workflow_log_page(), - ) - monkeypatch.setattr( - DifyAPIRepositoryFactory, - "create_api_workflow_run_repository", - lambda *_args, **_kwargs: _WorkflowRunRepositoryStub( - run=_make_workflow_run( - run_id="log-run-1", - created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), - finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), - ) - ), - ) + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun, WorkflowAppLog, Account)], indirect=True) + def test_success( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + ) -> None: + _persist_workflow_log(sqlite_session, tenant_id="tenant-1", app_id="a1") + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) api = WorkflowAppLogApi() handler = unwrap(api.get) @@ -803,18 +802,24 @@ class TestWorkflowRunDetailApiGet: and we call the unwrapped method directly in tests. """ - @patch("controllers.service_api.app.workflow.DifyAPIRepositoryFactory") - @patch("controllers.service_api.app.workflow.db") + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun,)], indirect=True) def test_get_workflow_run_success( self, - mock_db, - mock_repo_factory, app: Flask, workflow_app: App, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, ): """Test successful workflow run detail retrieval.""" - run = _make_workflow_run(run_id="run-1") - mock_repo_factory.create_api_workflow_run_repository.return_value = _WorkflowRunRepositoryStub(run=run) + run = _make_workflow_run( + run_id="run-1", + tenant_id=workflow_app.tenant_id, + app_id=workflow_app.id, + ) + sqlite_session.add(run) + sqlite_session.commit() + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) from controllers.service_api.app.workflow import WorkflowRunDetailApi @@ -834,13 +839,12 @@ class TestWorkflowRunDetailApiGet: "error": None, "total_steps": 1, "total_tokens": 10, - "created_at": 1767225600, - "finished_at": 1767225600, + "created_at": int(datetime(2026, 1, 1).timestamp()), + "finished_at": int(datetime(2026, 1, 1).timestamp()), "elapsed_time": 0.1, } - @patch("controllers.service_api.app.workflow.db") - def test_get_workflow_run_wrong_app_mode(self, mock_db, app: Flask): + def test_get_workflow_run_wrong_app_mode(self, app: Flask): """Test NotWorkflowAppError when app mode is not workflow or advanced_chat.""" from controllers.service_api.app.workflow import WorkflowRunDetailApi @@ -902,46 +906,23 @@ class TestWorkflowAppLogApiGet: ``get`` is wrapped by ``@validate_app_token``. """ - @patch("controllers.service_api.app.workflow.WorkflowAppService") - @patch("controllers.service_api.app.workflow.db") + @pytest.mark.parametrize("sqlite_session", [(WorkflowRun, WorkflowAppLog, Account)], indirect=True) def test_get_workflow_logs_success( self, - mock_db, - mock_wf_svc_cls, app: Flask, workflow_app: App, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, ): """Test successful workflow log retrieval.""" - mock_svc_instance = Mock() - mock_svc_instance.get_paginate_workflow_app_logs.return_value = _make_workflow_log_page() - mock_wf_svc_cls.return_value = mock_svc_instance - mock_repo = _WorkflowRunRepositoryStub( - run=_make_workflow_run( - run_id="log-run-1", - created_at=datetime(2026, 1, 1, 1, tzinfo=UTC), - finished_at=datetime(2026, 1, 1, 1, 0, 2, tzinfo=UTC), - ) - ) - - # Mock sessionmaker(...).begin() context manager - mock_db.engine = object() - mock_db.session.get.return_value = None + _persist_workflow_log(sqlite_session, tenant_id=workflow_app.tenant_id, app_id=workflow_app.id) + _bind_sqlite_database(monkeypatch, sqlite_engine, sqlite_session) from controllers.service_api.app.workflow import WorkflowAppLogApi - with app.test_request_context( - "/workflows/logs?page=1&limit=20", - method="GET", - ): - with ( - patch("controllers.service_api.app.workflow.sessionmaker", _SessionMakerStub), - patch("models.workflow.db", _DbStub()), - patch( - "repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository", - return_value=mock_repo, - ), - ): - api = WorkflowAppLogApi() - result = unwrap(api.get)(api, app_model=workflow_app) + with app.test_request_context("/workflows/logs?page=1&limit=20", method="GET"): + api = WorkflowAppLogApi() + result = unwrap(api.get)(api, app_model=workflow_app) assert result == _expected_workflow_log_pagination_payload() From 01efc6eecbde3cfb6eb37c720f90249830ac7d7e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:51:49 +0900 Subject: [PATCH 20/63] test: use SQLite sessions in service API fixtures (#38784) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/tests/unit_tests/conftest.py | 62 ++++++++------ .../controllers/service_api/conftest.py | 83 ++++++++++--------- .../controllers/service_api/test_conftest.py | 55 ++++++++++++ 3 files changed, 137 insertions(+), 63 deletions(-) create mode 100644 api/tests/unit_tests/controllers/service_api/test_conftest.py diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index d95e5e8501d..0714ef1bd89 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -37,6 +37,7 @@ os.environ.setdefault("STORAGE_TYPE", "opendal") from core.db.session_factory import configure_session_factory, session_factory from extensions import ext_redis +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase @@ -148,32 +149,45 @@ def _configure_session_factory(_unit_test_engine): configure_session_factory(_unit_test_engine, expire_on_commit=False) -def setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_owner): - """ - Helper to stub the tenant-owner execute result for service API app authentication. +def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin: + """Persist the owner identity resolved by service-API app authentication. - The validate_app_token decorator currently resolves the active tenant owner - via db.session.execute(select(Tenant, Account)...).one_or_none(). - - Args: - mock_db: The mocked db object - mock_tenant: Mock tenant object to return - mock_owner: Mock owner object to return from the execute result + The legacy name is retained temporarily for consumers on independent + conversion branches, but this helper no longer fabricates an execute result. """ + membership = TenantAccountJoin( + tenant_id=tenant.id, + account_id=owner.id, + role=TenantAccountRole.OWNER, + ) + owner._current_tenant = tenant + session.add_all([tenant, owner, membership]) + session.commit() + return membership + + +def persist_service_api_dataset_owner( + session: Session, + tenant: Tenant, + tenant_account_join: TenantAccountJoin, +) -> None: + """Persist the tenant-owner mapping resolved by dataset-token authentication.""" + session.add_all([tenant, tenant_account_join]) + session.commit() + + +def setup_mock_tenant_owner_execute_result(mock_db: MagicMock, mock_tenant: object, mock_owner: object) -> None: + """Stub the legacy owner query; SQLite-backed tests use ``persist_service_api_tenant_owner``.""" mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_owner) -def setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_tenant_account_join): - """ - Helper to stub the tenant-owner execute result for dataset token authentication. - - The validate_dataset_token decorator currently resolves the owner mapping via - db.session.execute(select(Tenant, TenantAccountJoin)...).one_or_none(), and - then loads the Account separately via db.session.get(...). - - Args: - mock_db: The mocked db object - mock_tenant: Mock tenant object to return - mock_tenant_account_join: Mock tenant-account join object to return - """ - mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_tenant_account_join) +def setup_mock_dataset_owner_execute_result( + mock_db: MagicMock, + mock_tenant: object, + mock_tenant_account_join: object, +) -> None: + """Stub the legacy dataset-owner query; SQLite tests use ``persist_service_api_dataset_owner``.""" + mock_db.session.execute.return_value.one_or_none.return_value = ( + mock_tenant, + mock_tenant_account_join, + ) diff --git a/api/tests/unit_tests/controllers/service_api/conftest.py b/api/tests/unit_tests/controllers/service_api/conftest.py index fff64efd4cb..bede4d75850 100644 --- a/api/tests/unit_tests/controllers/service_api/conftest.py +++ b/api/tests/unit_tests/controllers/service_api/conftest.py @@ -7,18 +7,57 @@ Service API controller tests. """ import uuid +from collections.abc import Iterator +from dataclasses import dataclass from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy import Engine +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType -from models.account import TenantStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus +from models.base import TypeBase from models.model import App, AppMode, EndUser -from tests.unit_tests.conftest import ( - setup_mock_dataset_owner_execute_result, - setup_mock_tenant_owner_execute_result, -) + + +@dataclass(frozen=True) +class ServiceApiIdentity: + """Persisted owner identity for service-API authentication tests.""" + + session: Session + tenant: Tenant + account: Account + membership: TenantAccountJoin + + +@pytest.fixture +def service_api_identity(sqlite_engine: Engine) -> Iterator[ServiceApiIdentity]: + """Yield an isolated SQLite session with a real active tenant owner.""" + TypeBase.metadata.create_all( + sqlite_engine, + tables=[Account.__table__, Tenant.__table__, TenantAccountJoin.__table__], + ) + with Session(sqlite_engine, expire_on_commit=False) as session: + tenant = Tenant(name="Service API Workspace") + tenant.id = str(uuid.uuid4()) + account = Account(name="Service API Owner", email=f"owner-{tenant.id}@example.com") + account.id = str(uuid.uuid4()) + membership = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + ) + account._current_tenant = tenant + session.add_all([tenant, account, membership]) + session.commit() + yield ServiceApiIdentity( + session=session, + tenant=tenant, + account=account, + membership=membership, + ) @pytest.fixture @@ -110,40 +149,6 @@ def mock_dataset_api_token(mock_tenant_id): return token -class AuthenticationMocker: - """ - Helper class to set up common authentication mocking patterns. - - Usage: - auth_mocker = AuthenticationMocker() - with auth_mocker.mock_app_auth(mock_api_token, mock_app_model, mock_tenant): - # Test code here - """ - - @staticmethod - def setup_db_queries(mock_db, mock_app, mock_tenant, mock_account=None): - """Configure mock_db to return app and tenant via session.get().""" - mock_db.session.get.side_effect = [mock_app, mock_tenant] - - if mock_account: - setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) - - @staticmethod - def setup_dataset_auth(mock_db, mock_tenant, mock_account): - """Configure mock_db for dataset token authentication.""" - mock_ta = Mock() - mock_ta.account_id = mock_account.id - - setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta) - mock_db.session.get.return_value = mock_account - - -@pytest.fixture -def auth_mocker(): - """Provide an AuthenticationMocker instance.""" - return AuthenticationMocker() - - @pytest.fixture def mock_dataset(): """Create a mock Dataset model.""" diff --git a/api/tests/unit_tests/controllers/service_api/test_conftest.py b/api/tests/unit_tests/controllers/service_api/test_conftest.py new file mode 100644 index 00000000000..014d99a0636 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/test_conftest.py @@ -0,0 +1,55 @@ +"""State-based checks for shared service-API authentication fixtures.""" + +from uuid import uuid4 + +from sqlalchemy import select + +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from tests.unit_tests.conftest import ( + persist_service_api_dataset_owner, + persist_service_api_tenant_owner, +) +from tests.unit_tests.controllers.service_api.conftest import ServiceApiIdentity + + +def test_service_api_identity_persists_tenant_scoped_owner(service_api_identity: ServiceApiIdentity) -> None: + identity = service_api_identity + + owner_row = identity.session.execute( + select(Tenant, Account) + .join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id) + .join(Account, TenantAccountJoin.account_id == Account.id) + .where( + Tenant.id == identity.tenant.id, + TenantAccountJoin.role == TenantAccountRole.OWNER, + ) + ).one() + + assert owner_row == (identity.tenant, identity.account) + assert identity.account.current_tenant is identity.tenant + + +def test_shared_helpers_persist_real_app_and_dataset_owner_rows(service_api_identity: ServiceApiIdentity) -> None: + session = service_api_identity.session + app_tenant = Tenant(name="App Workspace") + app_tenant.id = str(uuid4()) + app_owner = Account(name="App Owner", email=f"app-owner-{app_tenant.id}@example.com") + app_owner.id = str(uuid4()) + + app_membership = persist_service_api_tenant_owner(session, app_tenant, app_owner) + + dataset_tenant = Tenant(name="Dataset Workspace") + dataset_tenant.id = str(uuid4()) + dataset_membership = TenantAccountJoin( + tenant_id=dataset_tenant.id, + account_id=service_api_identity.account.id, + role=TenantAccountRole.OWNER, + ) + persist_service_api_dataset_owner(session, dataset_tenant, dataset_membership) + + assert session.get(TenantAccountJoin, app_membership.id) is app_membership + assert session.execute( + select(Tenant, TenantAccountJoin) + .join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id) + .where(Tenant.id == dataset_tenant.id) + ).one() == (dataset_tenant, dataset_membership) From 7b95e2a75fa66c02a1d13ca880c6300e24298860 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:52:06 +0900 Subject: [PATCH 21/63] test: use sqlite3 session in test_generator_api_missing (#38719) --- .../console/app/test_generator_api_missing.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py b/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py index bdc3976e14e..d6f6bd703f4 100644 --- a/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py +++ b/api/tests/unit_tests/controllers/console/app/test_generator_api_missing.py @@ -1,5 +1,6 @@ import pytest from flask import Flask +from sqlalchemy.orm import Session from controllers.console.app import generator as generator_module from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError @@ -102,12 +103,14 @@ def test_structured_output_generate_exceptions(app: Flask, monkeypatch: pytest.M method(api, "t1") -def test_instruction_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_instruction_generate_exceptions( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, +) -> None: api = generator_module.InstructionGenerateApi() method = unwrap(api.post) - from types import SimpleNamespace - - session = SimpleNamespace() exceptions_to_test = [ (ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError), @@ -135,4 +138,4 @@ def test_instruction_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyP }, ): with pytest.raises(expected_exception): - method(api, session, "t1") + method(api, sqlite_session, "t1") From 072953a83aa461bdcb05321a03822b8259d9c1ae Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:52:19 +0900 Subject: [PATCH 22/63] test: use sqlite3 session in test_export_service (#38718) --- .../data_migration/test_export_service.py | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/api/tests/unit_tests/services/data_migration/test_export_service.py b/api/tests/unit_tests/services/data_migration/test_export_service.py index 5479de5ba22..dbb05386cb3 100644 --- a/api/tests/unit_tests/services/data_migration/test_export_service.py +++ b/api/tests/unit_tests/services/data_migration/test_export_service.py @@ -1,7 +1,8 @@ -from unittest.mock import MagicMock - import pytest +from sqlalchemy import event +from sqlalchemy.orm import Session +from models.tools import MCPToolProvider from services.data_migration.dependency_discovery_service import DiscoveredDependency from services.data_migration.entities import ( ConflictStrategy, @@ -12,6 +13,10 @@ from services.data_migration.entities import ( ) from services.data_migration.export_service import ExportConfigParser, MigrationExportService +_TENANT_ID = "11111111-1111-1111-1111-111111111111" +_OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" +_USER_ID = "33333333-3333-3333-3333-333333333333" + def test_export_config_parser_accepts_new_scripted_shape(): selection = ExportConfigParser().parse( @@ -121,7 +126,8 @@ def test_secret_free_api_tool_export_uses_masking_and_omits_credentials(monkeypa assert report_items[0].resource_type == ResourceType.API_TOOL -def test_secret_free_mcp_dependencies_are_dependency_only(): +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_secret_free_mcp_dependencies_are_dependency_only(sqlite_session: Session): service = MigrationExportService() dependencies: list[dict] = [] mcp_tools: list[dict] = [] @@ -134,9 +140,10 @@ def test_secret_free_mcp_dependencies_are_dependency_only(): exported_mcp_tools=mcp_tools, dependencies=dependencies, report_items=report_items, - session=MagicMock(), + session=sqlite_session, ) + assert not sqlite_session.in_transaction() assert mcp_tools == [] assert dependencies == [ { @@ -150,17 +157,36 @@ def test_secret_free_mcp_dependencies_are_dependency_only(): assert report_items[0].name == "mcp_tool mcp-1" -def test_get_mcp_provider_does_not_compare_non_uuid_identifier_to_uuid_id(): +@pytest.mark.parametrize("sqlite_session", [(MCPToolProvider,)], indirect=True) +def test_get_mcp_provider_does_not_compare_non_uuid_identifier_to_uuid_id(sqlite_session: Session): + sqlite_session.add( + MCPToolProvider( + name="Other tenant provider", + server_identifier="my-test-mcp", + server_url="https://example.com/mcp", + server_url_hash="other-tenant-provider", + icon=None, + tenant_id=_OTHER_TENANT_ID, + user_id=_USER_ID, + authed=False, + tools="[]", + ) + ) + sqlite_session.commit() + statements = [] - def capture_scalar(statement): - statements.append(str(statement)) + def capture_statement(_conn, _cursor, statement, _parameters, _context, _executemany): + statements.append(statement) - session = MagicMock() - session.scalar.side_effect = capture_scalar + bind = sqlite_session.get_bind() + event.listen(bind, "before_cursor_execute", capture_statement) - with pytest.raises(MigrationDataError, match="MCP provider not found"): - MigrationExportService()._get_mcp_provider("tenant-1", "my-test-mcp", session=session) + try: + with pytest.raises(MigrationDataError, match="MCP provider not found"): + MigrationExportService()._get_mcp_provider(_TENANT_ID, "my-test-mcp", session=sqlite_session) + finally: + event.remove(bind, "before_cursor_execute", capture_statement) assert len(statements) == 1 assert "tool_mcp_providers.id =" not in statements[0] From ba449890a9e52b28c62de1553b78de09da0b692c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:53:00 +0900 Subject: [PATCH 23/63] test: use sqlite3 session in test_app_runner_conversation_variables (#38687) --- .../test_app_runner_conversation_variables.py | 521 +++--------------- 1 file changed, 91 insertions(+), 430 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py index 1970e5c1522..1f592ddec82 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py @@ -1,459 +1,120 @@ -"""Test conversation variable handling in AdvancedChatAppRunner.""" +"""SQLite-backed conversation-variable synchronization tests for AdvancedChatAppRunner.""" -from unittest.mock import MagicMock, patch -from uuid import uuid4 +from unittest.mock import MagicMock +import pytest +from sqlalchemy import select from sqlalchemy.orm import Session +from core.app.apps.advanced_chat import app_runner as app_runner_module from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from factories import variable_factory from graphon.variables import SegmentType -from models import ConversationVariable, Workflow +from models import ConversationVariable -MINIMAL_GRAPH = { - "nodes": [ +APP_ID = "11111111-1111-1111-1111-111111111111" +CONVERSATION_ID = "22222222-2222-2222-2222-222222222222" +OTHER_CONVERSATION_ID = "22222222-2222-2222-2222-222222222223" +VAR_1_ID = "33333333-3333-3333-3333-333333333333" +VAR_2_ID = "33333333-3333-3333-3333-333333333334" + + +def _variable(variable_id: str, name: str, value: str): + return variable_factory.build_conversation_variable_from_mapping( { - "id": "start", - "data": { - "type": "start", - "title": "Start", - }, + "id": variable_id, + "name": name, + "value_type": SegmentType.STRING, + "value": value, } - ], - "edges": [], -} + ) -def _patch_create_session(mock_session: MagicMock): - session_context = MagicMock() - session_context.__enter__.return_value = mock_session - session_context.__exit__.return_value = False - mock_session.begin.return_value.__enter__.return_value = mock_session - mock_session.begin.return_value.__exit__.return_value = False - return patch("core.app.apps.advanced_chat.app_runner.create_session", return_value=session_context) +def _runner(workflow_variables: list[object]) -> AdvancedChatAppRunner: + workflow = MagicMock() + workflow.conversation_variables = workflow_variables + conversation = MagicMock(app_id=APP_ID, id=CONVERSATION_ID) + return AdvancedChatAppRunner( + application_generate_entity=MagicMock(), + queue_manager=MagicMock(), + conversation=conversation, + message=MagicMock(), + dialogue_count=1, + variable_loader=MagicMock(), + workflow=workflow, + system_user_id="44444444-4444-4444-4444-444444444444", + app=MagicMock(), + workflow_execution_repository=MagicMock(), + workflow_node_execution_repository=MagicMock(), + ) -class TestAdvancedChatAppRunnerConversationVariables: - """Test that AdvancedChatAppRunner correctly handles conversation variables.""" +def _bind_runner_sessions(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + engine = sqlite_session.get_bind() + monkeypatch.setattr( + app_runner_module, + "create_session", + lambda: Session(engine, expire_on_commit=False), + ) - def test_missing_conversation_variables_are_added(self): - """Test that new conversation variables added to workflow are created for existing conversations.""" - # Setup - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_id = str(uuid4()) - # Create workflow with two conversation variables - workflow_vars = [ - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var1", - "name": "existing_var", - "value_type": SegmentType.STRING, - "value": "default1", - } - ), - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var2", - "name": "new_var", - "value_type": SegmentType.STRING, - "value": "default2", - } - ), - ] - - # Mock workflow with conversation variables - mock_workflow = MagicMock(spec=Workflow) - mock_workflow.conversation_variables = workflow_vars - mock_workflow.tenant_id = str(uuid4()) - mock_workflow.app_id = app_id - mock_workflow.id = workflow_id - mock_workflow.type = "chat" - mock_workflow.graph_dict = MINIMAL_GRAPH - mock_workflow.environment_variables = [] - - # Create existing conversation variable (only var1 exists in DB) - existing_db_var = MagicMock(spec=ConversationVariable) - existing_db_var.id = "var1" - existing_db_var.app_id = app_id - existing_db_var.conversation_id = conversation_id - existing_db_var.to_variable = MagicMock(return_value=workflow_vars[0]) - - # Mock conversation and message - mock_conversation = MagicMock() - mock_conversation.app_id = app_id - mock_conversation.id = conversation_id - - mock_message = MagicMock() - mock_message.id = str(uuid4()) - - # Mock app config - mock_app_config = MagicMock() - mock_app_config.app_id = app_id - mock_app_config.workflow_id = workflow_id - mock_app_config.tenant_id = str(uuid4()) - - # Mock app generate entity - mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity) - mock_app_generate_entity.app_config = mock_app_config - mock_app_generate_entity.inputs = {} - mock_app_generate_entity.query = "test query" - mock_app_generate_entity.files = [] - mock_app_generate_entity.user_id = str(uuid4()) - mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API - mock_app_generate_entity.workflow_run_id = str(uuid4()) - mock_app_generate_entity.task_id = str(uuid4()) - mock_app_generate_entity.call_depth = 0 - mock_app_generate_entity.single_iteration_run = None - mock_app_generate_entity.single_loop_run = None - mock_app_generate_entity.extras = {} - mock_app_generate_entity.trace_manager = None - - # Create runner - runner = AdvancedChatAppRunner( - application_generate_entity=mock_app_generate_entity, - queue_manager=MagicMock(), - conversation=mock_conversation, - message=mock_message, - dialogue_count=1, - variable_loader=MagicMock(), - workflow=mock_workflow, - system_user_id=str(uuid4()), - app=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), +def _persist_variable(session: Session, *, variable: object, conversation_id: str = CONVERSATION_ID) -> None: + session.add( + ConversationVariable.from_variable( + app_id=APP_ID, + conversation_id=conversation_id, + variable=variable, ) + ) + session.commit() - # Mock database session - mock_session = MagicMock(spec=Session) - # First query returns only existing variable - mock_scalars_result = MagicMock() - mock_scalars_result.all.return_value = [existing_db_var] - mock_session.scalars.return_value = mock_scalars_result +@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) +def test_missing_conversation_variables_are_added(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + existing_variable = _variable(VAR_1_ID, "existing_var", "default1") + new_variable = _variable(VAR_2_ID, "new_var", "default2") + _persist_variable(sqlite_session, variable=existing_variable) + _persist_variable(sqlite_session, variable=new_variable, conversation_id=OTHER_CONVERSATION_ID) + _bind_runner_sessions(monkeypatch, sqlite_session) - # Track what gets added to session - added_items = [] + variables = _runner([existing_variable, new_variable])._initialize_conversation_variables() - def track_add_all(items): - added_items.extend(items) + assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] + persisted = sqlite_session.scalars( + select(ConversationVariable) + .where(ConversationVariable.conversation_id == CONVERSATION_ID) + .order_by(ConversationVariable.id) + ).all() + assert [variable.id for variable in persisted] == [VAR_1_ID, VAR_2_ID] - mock_session.add_all.side_effect = track_add_all - # Patch the necessary components - with ( - _patch_create_session(mock_session), - patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch.object(runner, "_init_graph") as mock_init_graph, - patch.object( - runner, - "handle_input_moderation", - return_value=(False, mock_app_generate_entity.inputs, mock_app_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class, - patch("core.app.apps.advanced_chat.app_runner.GraphRuntimeState") as mock_graph_runtime_state_class, - patch("core.app.apps.advanced_chat.app_runner.redis_client") as mock_redis_client, - patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, - ): - # Mock GraphRuntimeState to accept the variable pool - mock_graph_runtime_state_class.return_value = MagicMock() +@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) +def test_no_variables_creates_all(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + workflow_variables = [ + _variable(VAR_1_ID, "var1", "default1"), + _variable(VAR_2_ID, "var2", "default2"), + ] + _bind_runner_sessions(monkeypatch, sqlite_session) - # Mock graph initialization - mock_init_graph.return_value = MagicMock() + variables = _runner(workflow_variables)._initialize_conversation_variables() - # Mock workflow entry - mock_workflow_entry = MagicMock() - mock_workflow_entry.run.return_value = iter([]) # Empty generator - mock_workflow_entry_class.return_value = mock_workflow_entry + assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] + persisted = sqlite_session.scalars(select(ConversationVariable).order_by(ConversationVariable.id)).all() + assert [variable.id for variable in persisted] == [VAR_1_ID, VAR_2_ID] - # Run the method - runner.run() - # Verify that the missing variable was added - assert len(added_items) == 1, "Should have added exactly one missing variable" +@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) +def test_all_variables_exist_no_changes(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + workflow_variables = [ + _variable(VAR_1_ID, "var1", "default1"), + _variable(VAR_2_ID, "var2", "default2"), + ] + for variable in workflow_variables: + _persist_variable(sqlite_session, variable=variable) + _bind_runner_sessions(monkeypatch, sqlite_session) - # Check that the added item is the missing variable (var2) - added_var = added_items[0] - assert hasattr(added_var, "id"), "Added item should be a ConversationVariable" - # Note: Since we're mocking ConversationVariable.from_variable, - # we can't directly check the id, but we can verify add_all was called - assert mock_session.add_all.called, "Session add_all should have been called" + variables = _runner(workflow_variables)._initialize_conversation_variables() - def test_no_variables_creates_all(self): - """Test that all conversation variables are created when none exist in DB.""" - # Setup - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_id = str(uuid4()) - - # Create workflow with conversation variables - workflow_vars = [ - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var1", - "name": "var1", - "value_type": SegmentType.STRING, - "value": "default1", - } - ), - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var2", - "name": "var2", - "value_type": SegmentType.STRING, - "value": "default2", - } - ), - ] - - # Mock workflow - mock_workflow = MagicMock(spec=Workflow) - mock_workflow.conversation_variables = workflow_vars - mock_workflow.tenant_id = str(uuid4()) - mock_workflow.app_id = app_id - mock_workflow.id = workflow_id - mock_workflow.type = "chat" - mock_workflow.graph_dict = MINIMAL_GRAPH - mock_workflow.environment_variables = [] - - # Mock conversation and message - mock_conversation = MagicMock() - mock_conversation.app_id = app_id - mock_conversation.id = conversation_id - - mock_message = MagicMock() - mock_message.id = str(uuid4()) - - # Mock app config - mock_app_config = MagicMock() - mock_app_config.app_id = app_id - mock_app_config.workflow_id = workflow_id - mock_app_config.tenant_id = str(uuid4()) - - # Mock app generate entity - mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity) - mock_app_generate_entity.app_config = mock_app_config - mock_app_generate_entity.inputs = {} - mock_app_generate_entity.query = "test query" - mock_app_generate_entity.files = [] - mock_app_generate_entity.user_id = str(uuid4()) - mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API - mock_app_generate_entity.workflow_run_id = str(uuid4()) - mock_app_generate_entity.task_id = str(uuid4()) - mock_app_generate_entity.call_depth = 0 - mock_app_generate_entity.single_iteration_run = None - mock_app_generate_entity.single_loop_run = None - mock_app_generate_entity.extras = {} - mock_app_generate_entity.trace_manager = None - - # Create runner - runner = AdvancedChatAppRunner( - application_generate_entity=mock_app_generate_entity, - queue_manager=MagicMock(), - conversation=mock_conversation, - message=mock_message, - dialogue_count=1, - variable_loader=MagicMock(), - workflow=mock_workflow, - system_user_id=str(uuid4()), - app=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), - ) - - # Mock database session - mock_session = MagicMock(spec=Session) - - # Query returns empty list (no existing variables) - mock_scalars_result = MagicMock() - mock_scalars_result.all.return_value = [] - mock_session.scalars.return_value = mock_scalars_result - - # Track what gets added to session - added_items = [] - - def track_add_all(items): - added_items.extend(items) - - mock_session.add_all.side_effect = track_add_all - - # Patch the necessary components - with ( - _patch_create_session(mock_session), - patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch.object(runner, "_init_graph") as mock_init_graph, - patch.object( - runner, - "handle_input_moderation", - return_value=(False, mock_app_generate_entity.inputs, mock_app_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class, - patch("core.app.apps.advanced_chat.app_runner.GraphRuntimeState") as mock_graph_runtime_state_class, - patch("core.app.apps.advanced_chat.app_runner.ConversationVariable") as mock_conv_var_class, - patch("core.app.apps.advanced_chat.app_runner.redis_client") as mock_redis_client, - patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, - ): - # Mock ConversationVariable.from_variable to return mock objects - mock_conv_vars = [] - for var in workflow_vars: - mock_cv = MagicMock() - mock_cv.id = var.id - mock_cv.to_variable.return_value = var - mock_conv_vars.append(mock_cv) - - mock_conv_var_class.from_variable.side_effect = mock_conv_vars - - # Mock GraphRuntimeState to accept the variable pool - mock_graph_runtime_state_class.return_value = MagicMock() - - # Mock graph initialization - mock_init_graph.return_value = MagicMock() - - # Mock workflow entry - mock_workflow_entry = MagicMock() - mock_workflow_entry.run.return_value = iter([]) # Empty generator - mock_workflow_entry_class.return_value = mock_workflow_entry - - # Run the method - runner.run() - - # Verify that all variables were created - assert len(added_items) == 2, "Should have added both variables" - assert mock_session.add_all.called, "Session add_all should have been called" - - def test_all_variables_exist_no_changes(self): - """Test that no changes are made when all variables already exist in DB.""" - # Setup - app_id = str(uuid4()) - conversation_id = str(uuid4()) - workflow_id = str(uuid4()) - - # Create workflow with conversation variables - workflow_vars = [ - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var1", - "name": "var1", - "value_type": SegmentType.STRING, - "value": "default1", - } - ), - variable_factory.build_conversation_variable_from_mapping( - { - "id": "var2", - "name": "var2", - "value_type": SegmentType.STRING, - "value": "default2", - } - ), - ] - - # Mock workflow - mock_workflow = MagicMock(spec=Workflow) - mock_workflow.conversation_variables = workflow_vars - mock_workflow.tenant_id = str(uuid4()) - mock_workflow.app_id = app_id - mock_workflow.id = workflow_id - mock_workflow.type = "chat" - mock_workflow.graph_dict = MINIMAL_GRAPH - mock_workflow.environment_variables = [] - - # Create existing conversation variables (both exist in DB) - existing_db_vars = [] - for var in workflow_vars: - db_var = MagicMock(spec=ConversationVariable) - db_var.id = var.id - db_var.app_id = app_id - db_var.conversation_id = conversation_id - db_var.to_variable = MagicMock(return_value=var) - existing_db_vars.append(db_var) - - # Mock conversation and message - mock_conversation = MagicMock() - mock_conversation.app_id = app_id - mock_conversation.id = conversation_id - - mock_message = MagicMock() - mock_message.id = str(uuid4()) - - # Mock app config - mock_app_config = MagicMock() - mock_app_config.app_id = app_id - mock_app_config.workflow_id = workflow_id - mock_app_config.tenant_id = str(uuid4()) - - # Mock app generate entity - mock_app_generate_entity = MagicMock(spec=AdvancedChatAppGenerateEntity) - mock_app_generate_entity.app_config = mock_app_config - mock_app_generate_entity.inputs = {} - mock_app_generate_entity.query = "test query" - mock_app_generate_entity.files = [] - mock_app_generate_entity.user_id = str(uuid4()) - mock_app_generate_entity.invoke_from = InvokeFrom.SERVICE_API - mock_app_generate_entity.workflow_run_id = str(uuid4()) - mock_app_generate_entity.task_id = str(uuid4()) - mock_app_generate_entity.call_depth = 0 - mock_app_generate_entity.single_iteration_run = None - mock_app_generate_entity.single_loop_run = None - mock_app_generate_entity.extras = {} - mock_app_generate_entity.trace_manager = None - - # Create runner - runner = AdvancedChatAppRunner( - application_generate_entity=mock_app_generate_entity, - queue_manager=MagicMock(), - conversation=mock_conversation, - message=mock_message, - dialogue_count=1, - variable_loader=MagicMock(), - workflow=mock_workflow, - system_user_id=str(uuid4()), - app=MagicMock(), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), - ) - - # Mock database session - mock_session = MagicMock(spec=Session) - - # Query returns all existing variables - mock_scalars_result = MagicMock() - mock_scalars_result.all.return_value = existing_db_vars - mock_session.scalars.return_value = mock_scalars_result - - # Patch the necessary components - with ( - _patch_create_session(mock_session), - patch("core.app.apps.advanced_chat.app_runner.select") as mock_select, - patch.object(runner, "_init_graph") as mock_init_graph, - patch.object( - runner, - "handle_input_moderation", - return_value=(False, mock_app_generate_entity.inputs, mock_app_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch("core.app.apps.advanced_chat.app_runner.WorkflowEntry") as mock_workflow_entry_class, - patch("core.app.apps.advanced_chat.app_runner.GraphRuntimeState") as mock_graph_runtime_state_class, - patch("core.app.apps.advanced_chat.app_runner.redis_client") as mock_redis_client, - patch("core.app.apps.advanced_chat.app_runner.RedisChannel") as mock_redis_channel_class, - ): - # Mock GraphRuntimeState to accept the variable pool - mock_graph_runtime_state_class.return_value = MagicMock() - - # Mock graph initialization - mock_init_graph.return_value = MagicMock() - - # Mock workflow entry - mock_workflow_entry = MagicMock() - mock_workflow_entry.run.return_value = iter([]) # Empty generator - mock_workflow_entry_class.return_value = mock_workflow_entry - - # Run the method - runner.run() - - # Verify that no variables were added - assert not mock_session.add_all.called, "Session add_all should not have been called" + assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] + persisted = sqlite_session.scalars(select(ConversationVariable)).all() + assert len(persisted) == 2 From c5d471dbddfa2fb3efe5cf785640aa5c1343db69 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 13:53:13 +0900 Subject: [PATCH 24/63] test: use sqlite3 session in test_model_config_api (#38675) --- .../console/app/test_model_config_api.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/app/test_model_config_api.py b/api/tests/unit_tests/controllers/console/app/test_model_config_api.py index 8257605fed4..d95214cf0ac 100644 --- a/api/tests/unit_tests/controllers/console/app/test_model_config_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_model_config_api.py @@ -11,7 +11,7 @@ import pytest from flask import Flask from sqlalchemy import func, select from sqlalchemy.engine import Engine -from sqlalchemy.orm import object_session, sessionmaker +from sqlalchemy.orm import Session, object_session, sessionmaker from controllers.common import session as controller_session from controllers.console.app import model_config as model_config_module @@ -29,11 +29,14 @@ def _poison_implicit_app_config_properties(monkeypatch: pytest.MonkeyPatch) -> N @pytest.mark.parametrize("app_mode", [AppMode.CHAT, AppMode.COMPLETION]) +@pytest.mark.parametrize("sqlite_session", [(AppModelConfig,)], indirect=True) def test_post_updates_non_agent_model_config_without_implicit_properties( app: Flask, monkeypatch: pytest.MonkeyPatch, app_mode: AppMode, + sqlite_session: Session, ) -> None: + """Flush a non-agent config through the injected session without legacy model properties.""" api = model_config_module.ModelConfigResource() method = unwrap(api.post) @@ -45,14 +48,16 @@ def test_post_updates_non_agent_model_config_without_implicit_properties( updated_at=None, ) original_config = AppModelConfig(app_id="app-1", created_by="u1", updated_by="u1") + original_config.id = "config-0" original_config.agent_mode = None + sqlite_session.add(original_config) + sqlite_session.commit() _poison_implicit_app_config_properties(monkeypatch) monkeypatch.setattr( model_config_module.AppModelConfigService, "validate_configuration", lambda **_kwargs: {"pre_prompt": "hi"}, ) - session = MagicMock() def _from_model_config_dict(self, model_config): self.pre_prompt = model_config["pre_prompt"] @@ -62,18 +67,16 @@ def test_post_updates_non_agent_model_config_without_implicit_properties( monkeypatch.setattr(AppModelConfig, "from_model_config_dict", _from_model_config_dict) send_mock = MagicMock() monkeypatch.setattr(model_config_module.app_model_config_was_updated, "send", send_mock) - session.get.return_value = original_config with app.test_request_context("/console/api/apps/app-1/model-config", method="POST", json={"pre_prompt": "hi"}): - response = method(api, session, "t1", "u1", app_model=app_model) + response = method(api, sqlite_session, "t1", "u1", app_model=app_model) - session.get.assert_called_once_with(AppModelConfig, "config-0") - session.add.assert_called_once() - session.flush.assert_called_once() - session.commit.assert_not_called() - assert send_mock.call_args.kwargs["session"] is session + assert send_mock.call_args.kwargs["session"] is sqlite_session assert app_model.app_model_config_id == "config-1" assert app_model.mode == app_mode + persisted_config = sqlite_session.get(AppModelConfig, "config-1") + assert persisted_config is not None + assert persisted_config.pre_prompt == "hi" assert response["result"] == "success" @@ -160,7 +163,11 @@ def test_post_uses_one_session_and_rolls_back_when_signal_fails( assert config_count == 1 -def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(AppModelConfig,)], indirect=True) +def test_post_encrypts_agent_tool_parameters( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + """Agent parameter encryption reads and writes persisted model configurations.""" api = model_config_module.ModelConfigResource() method = unwrap(api.post) @@ -174,6 +181,7 @@ def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.Mon _poison_implicit_app_config_properties(monkeypatch) original_config = AppModelConfig(app_id="app-1", created_by="u1", updated_by="u1") + original_config.id = "config-0" original_config.agent_mode = json.dumps( { "enabled": True, @@ -190,8 +198,8 @@ def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.Mon } ) - session = MagicMock() - session.scalar.return_value = original_config + sqlite_session.add(original_config) + sqlite_session.commit() monkeypatch.setattr( model_config_module.AppModelConfigService, @@ -236,11 +244,11 @@ def test_post_encrypts_agent_tool_parameters(app: Flask, monkeypatch: pytest.Mon monkeypatch.setattr(model_config_module.app_model_config_was_updated, "send", send_mock) with app.test_request_context("/console/api/apps/app-1/model-config", method="POST", json={"pre_prompt": "hi"}): - response = method(api, session, "t1", "u1", app_model=app_model) + response = method(api, sqlite_session, "t1", "u1", app_model=app_model) - stored_config = session.add.call_args[0][0] + stored_config = sqlite_session.get(AppModelConfig, app_model.app_model_config_id) + assert stored_config is not None stored_agent_mode = json.loads(stored_config.agent_mode) - session.scalar.assert_called_once() assert app_model.mode == AppMode.AGENT_CHAT assert stored_agent_mode["tools"][0]["tool_parameters"]["secret"] == "encrypted" assert response["result"] == "success" From 05a25943e77a2a8234bdf544699baeda9e4382a0 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:09:23 +0800 Subject: [PATCH 25/63] fix(e2e): align fixtures with generated contracts (#39394) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/app.py | 14 +- api/controllers/console/app/workflow.py | 29 +- api/openapi/markdown/console-openapi.md | 43 ++- .../commands/test_generate_swagger_specs.py | 11 + e2e/features/agent-v2/support/access-point.ts | 31 +- .../agent-v2/support/agent-build-draft.ts | 12 +- e2e/features/agent-v2/support/agent-drive.ts | 16 +- e2e/features/agent-v2/support/agent.ts | 63 ++-- .../agent-v2/support/fixtures/access.ts | 7 +- .../agent-v2/support/fixtures/agents.ts | 13 +- .../agent-v2/support/fixtures/common.ts | 7 +- .../agent-v2/support/fixtures/datasets.ts | 11 +- .../agent-v2/support/fixtures/models.ts | 9 +- .../agent-v2/support/fixtures/tools.ts | 7 +- e2e/features/agent-v2/support/seed.ts | 37 ++- e2e/features/agent-v2/support/workflow.ts | 72 +++++ e2e/features/agent-v2/tools.feature | 4 +- .../agent-v2/access-point.steps.ts | 7 +- .../agent-v2/output-variables.steps.ts | 26 +- .../step-definitions/agent-v2/tools.steps.ts | 17 +- .../agent-v2/workflow-node.steps.ts | 3 +- .../apps/duplicate-app.steps.ts | 2 +- .../step-definitions/apps/share-app.steps.ts | 9 +- .../apps/switch-app-mode.steps.ts | 2 +- .../apps/web-app-service.steps.ts | 10 +- .../apps/workflow-run.steps.ts | 2 +- .../step-definitions/common/app.steps.ts | 7 +- e2e/features/support/hooks.ts | 6 +- e2e/support/api.ts | 270 ------------------ e2e/support/api/apps.ts | 36 +++ e2e/support/api/console-context.ts | 34 +++ e2e/support/{ => api}/datasets.ts | 4 +- e2e/support/api/marketplace-plugins.ts | 122 ++++++++ e2e/support/api/tools.ts | 24 ++ e2e/support/api/web-apps.ts | 47 +++ e2e/support/api/workflows.ts | 115 ++++++++ e2e/support/marketplace-plugins.ts | 140 ++------- e2e/support/tools.ts | 22 -- .../generated/api/console/agent/types.gen.ts | 38 ++- .../generated/api/console/agent/zod.gen.ts | 131 +++++---- .../generated/api/console/apps/types.gen.ts | 59 ++-- .../generated/api/console/apps/zod.gen.ts | 233 ++++++++------- 42 files changed, 972 insertions(+), 780 deletions(-) create mode 100644 e2e/features/agent-v2/support/workflow.ts delete mode 100644 e2e/support/api.ts create mode 100644 e2e/support/api/apps.ts create mode 100644 e2e/support/api/console-context.ts rename e2e/support/{ => api}/datasets.ts (68%) create mode 100644 e2e/support/api/marketplace-plugins.ts create mode 100644 e2e/support/api/tools.ts create mode 100644 e2e/support/api/web-apps.ts create mode 100644 e2e/support/api/workflows.ts delete mode 100644 e2e/support/tools.ts diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 335aefdaf2d..a286768d380 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -246,7 +246,7 @@ class ModelConfigPartial(ResponseModel): return to_timestamp(value) -class ModelConfig(ResponseModel): +class AppModelConfigResponse(ResponseModel): opening_statement: str | None = None suggested_questions: Any | None = Field( default=None, validation_alias=AliasChoices("suggested_questions_list", "suggested_questions") @@ -419,7 +419,7 @@ class AppDetail(AppResponseModel): icon_background: str | None = None enable_site: bool enable_api: bool - model_config_: ModelConfig | None = Field( + model_config_: AppModelConfigResponse | None = Field( default=None, validation_alias=AliasChoices("app_model_config", "model_config"), alias="model_config", @@ -525,7 +525,13 @@ def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) register_response_schema_models( - console_ns, RedirectUrlResponse, SimpleResultResponse, AppImportResponse, AppTraceResponse + console_ns, + RedirectUrlResponse, + SimpleResultResponse, + AppImportResponse, + AppTraceResponse, + AppModelConfigResponse, + AppDetail, ) register_schema_models( @@ -544,10 +550,8 @@ register_schema_models( Tag, WorkflowPartial, ModelConfigPartial, - ModelConfig, AppDetailSiteResponse, DeletedTool, - AppDetail, AppExportResponse, Segmentation, PreProcessingRule, diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 4be0ab2211d..6025d02fe39 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -359,6 +359,12 @@ class WorkflowPublishResponse(ResponseModel): created_at: int +class SyncDraftWorkflowResponse(ResponseModel): + result: str + hash: str + updated_at: int + + class WorkflowRestoreResponse(ResponseModel): result: str hash: str @@ -441,6 +447,7 @@ register_response_schema_models( WorkflowOnlineUsersByApp, WorkflowOnlineUsersResponse, WorkflowPublishResponse, + SyncDraftWorkflowResponse, WorkflowRestoreResponse, DefaultBlockConfigsResponse, DefaultBlockConfigResponse, @@ -556,14 +563,7 @@ class DraftWorkflowApi(Resource): @console_ns.response( 200, "Draft workflow synced successfully", - console_ns.model( - "SyncDraftWorkflowResponse", - { - "result": fields.String, - "hash": fields.String, - "updated_at": fields.String, - }, - ), + console_ns.models[SyncDraftWorkflowResponse.__name__], ) @console_ns.response(400, "Invalid workflow configuration") @console_ns.response(403, "Permission denied") @@ -618,11 +618,14 @@ class DraftWorkflowApi(Resource): except VariableError as e: raise InvalidArgumentError(description=str(e)) - return { - "result": "success", - "hash": workflow.unique_hash, - "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), - } + return dump_response( + SyncDraftWorkflowResponse, + { + "result": "success", + "hash": workflow.unique_hash, + "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), + }, + ) @console_ns.route("/apps//advanced-chat/workflows/draft/run") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ecefaac1954..7b3a11ccf91 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -13278,7 +13278,7 @@ Model class for AI model. | maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | -| model_config | [ModelConfig](#modelconfig) | | No | +| model_config | [AppModelConfigResponse](#appmodelconfigresponse) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | | role | string | | No | @@ -15348,7 +15348,6 @@ This class is used to store the schema information of an api based tool. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | -| app_model_config | [ModelConfig](#modelconfig) | | No | | created_at | integer | | No | | created_by | string | | No | | description | string | | No | @@ -15358,7 +15357,8 @@ This class is used to store the schema information of an api based tool. | icon_background | string | | No | | id | string | | Yes | | maintainer | string | | No | -| mode_compatible_with_agent | string | | Yes | +| mode | string | | Yes | +| model_config | [AppModelConfigResponse](#appmodelconfigresponse) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | @@ -15420,7 +15420,7 @@ This class is used to store the schema information of an api based tool. | maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | -| model_config | [ModelConfig](#modelconfig) | | No | +| model_config | [AppModelConfigResponse](#appmodelconfigresponse) | | No | | name | string | | Yes | | permission_keys | [ string ] | | No | | site | [AppDetailSiteResponse](#appdetailsiteresponse) | | No | @@ -15519,6 +15519,35 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | AppMCPServerStatus | string | AppMCPServer Status Enum | | +#### AppModelConfigResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_mode | | | No | +| annotation_reply | | | No | +| chat_prompt_config | | | No | +| completion_prompt_config | | | No | +| created_at | integer | | No | +| created_by | string | | No | +| dataset_configs | | | No | +| dataset_query_variable | string | | No | +| external_data_tools | | | No | +| file_upload | | | No | +| model | | | No | +| more_like_this | | | No | +| opening_statement | string | | No | +| pre_prompt | string | | No | +| prompt_type | string | | No | +| retriever_resource | | | No | +| sensitive_word_avoidance | | | No | +| speech_to_text | | | No | +| suggested_questions | | | No | +| suggested_questions_after_answer | | | No | +| text_to_speech | | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| user_input_form | | | No | + #### AppNamePayload | Name | Type | Description | Required | @@ -21954,9 +21983,9 @@ The subscription constructor of the trigger provider | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| hash | string | | No | -| result | string | | No | -| updated_at | string | | No | +| hash | string | | Yes | +| result | string | | Yes | +| updated_at | integer | | Yes | #### SystemConfigurationResponse 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 a0caf0263e7..72669270ad1 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -212,6 +212,17 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp assert {"type": "null"} in app_detail_nullable_schema["anyOf"] assert schemas["RecommendedAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True assert schemas["InstalledAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True + assert _response_schema(paths["/apps/{app_id}"]["get"])["$ref"] == "#/components/schemas/AppDetailWithSite" + app_model_config = schemas["AppDetailWithSite"]["properties"]["model_config"] + assert {"$ref": "#/components/schemas/AppModelConfigResponse"} in app_model_config["anyOf"] + app_detail = schemas["AppDetail"] + assert "mode" in app_detail["properties"] + assert "mode_compatible_with_agent" not in app_detail["properties"] + sync_draft_workflow = schemas["SyncDraftWorkflowResponse"] + assert _response_schema(paths["/apps/{app_id}/workflows/draft"]["post"])["$ref"] == ( + "#/components/schemas/SyncDraftWorkflowResponse" + ) + assert sync_draft_workflow["properties"]["updated_at"]["type"] == "integer" tool_icon_schema = schemas["ExploreAppMetaResponse"]["properties"]["tool_icons"]["additionalProperties"] assert {"type": "string"} in tool_icon_schema["anyOf"] assert {"additionalProperties": True, "type": "object"} in tool_icon_schema["anyOf"] diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index fa0b53e5b25..4d5dea98973 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -1,12 +1,18 @@ import type { AgentApiAccessResponse, + AgentApiStatusPayload, ApiKeyItem, } from '@dify/contracts/api/console/agent/types.gen' import type { ChatRequestPayloadWithUser, PostChatMessagesResponse, } from '@dify/contracts/api/service/types.gen' -import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from '../../../support/api' +import { + zPostAgentByAgentIdApiEnableResponse, + zPostAgentByAgentIdApiKeysResponse, +} from '@dify/contracts/api/console/agent/zod.gen' +import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' +import { setAppSiteEnabled } from '../../../support/api/web-apps' import { getTestAgent } from './agent' import { consumeServiceApiSse, SERVICE_API_STREAM_TIMEOUT_MS } from './service-api-sse' @@ -38,47 +44,40 @@ async function parseServiceApiChatResponse(response: Response) { } } -export async function setAgentSiteAccessAndGetURL( - agentId: string, - enabled: boolean, -): Promise { +export async function setAgentSiteAccess(agentId: string, enabled: boolean): Promise { const agent = await getTestAgent(agentId) const appId = agent.app_id ?? agent.backing_app_id if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) - const appDetail = await setAppSiteEnabled(appId, enabled) - const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site?.access_token - const baseURL = agent.site?.app_base_url ?? appDetail.site?.app_base_url - if (!token || !baseURL) throw new Error(`Agent v2 ${agentId} does not expose a Web App URL.`) - - return `${baseURL.replace(/\/$/, '')}/agent/${token}` + await setAppSiteEnabled(appId, enabled) } export async function setAgentApiAccess( agentId: string, enabled: boolean, ): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { + const data = { enable_api: enabled } satisfies AgentApiStatusPayload const response = await ctx.post(`/console/api/agent/${agentId}/api-enable`, { - data: { enable_api: enabled }, + data, }) await expectApiResponseOK( response, `${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`, ) - return (await response.json()) as AgentApiAccessResponse + return zPostAgentByAgentIdApiEnableResponse.parse(await response.json()) } finally { await ctx.dispose() } } export async function createAgentApiKey(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`) await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`) - return (await response.json()) as ApiKeyItem + return zPostAgentByAgentIdApiKeysResponse.parse(await response.json()) } finally { await ctx.dispose() } diff --git a/e2e/features/agent-v2/support/agent-build-draft.ts b/e2e/features/agent-v2/support/agent-build-draft.ts index 7d897ef3e91..6b1405654cb 100644 --- a/e2e/features/agent-v2/support/agent-build-draft.ts +++ b/e2e/features/agent-v2/support/agent-build-draft.ts @@ -2,10 +2,10 @@ import type { AgentBuildDraftResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { createApiContext, expectApiResponseOK } from '../../../support/api' +import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' export async function checkoutAgentBuildDraft(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/checkout`, { data: { force: true }, @@ -21,7 +21,7 @@ export async function saveAgentBuildDraft( agentId: string, agentSoul: AgentSoulConfig, ): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.put(`/console/api/agent/${agentId}/build-draft`, { data: { @@ -38,7 +38,7 @@ export async function saveAgentBuildDraft( } export async function agentBuildDraftExists(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) if (response.status() === 404) return false @@ -51,7 +51,7 @@ export async function agentBuildDraftExists(agentId: string): Promise { } export async function getAgentBuildDraft(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) @@ -62,7 +62,7 @@ export async function getAgentBuildDraft(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`) await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`) diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/agent-drive.ts index 28c4772f6dd..1b0fe20e189 100644 --- a/e2e/features/agent-v2/support/agent-drive.ts +++ b/e2e/features/agent-v2/support/agent-drive.ts @@ -10,7 +10,7 @@ import type { import { Buffer } from 'node:buffer' import { readFile } from 'node:fs/promises' import path from 'node:path' -import { createApiContext, expectApiResponseOK } from '../../../support/api' +import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' export type UploadedConsoleFile = { id: string @@ -126,7 +126,7 @@ export async function uploadAgentDriveSkill({ fileName: string filePath: string }): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const upload = await toSkillArchiveUpload({ fileName, filePath }) const response = await ctx.post(`/console/api/agent/${agentId}/skills/upload`, { @@ -154,7 +154,7 @@ export async function uploadAgentConfigFileToDraft({ fileName: string filePath: string }): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const uploadResponse = await ctx.post('/console/api/files/upload', { multipart: { @@ -203,7 +203,7 @@ export async function uploadAgentConfigSkillToDraft({ fileName: string filePath: string }): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const upload = await toSkillArchiveUpload({ fileName, filePath }) const response = await ctx.post(`/console/api/agent/${agentId}/config/skills/upload`, { @@ -236,7 +236,7 @@ export async function uploadAgentConfigSkillToDraft({ } export async function getAgentDriveSkills(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/drive/skills`) await expectApiResponseOK(response, `Get Agent v2 drive skills for ${agentId}`) @@ -248,7 +248,7 @@ export async function getAgentDriveSkills(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.delete( `/console/api/agent/${agentId}/config/files/${encodeURIComponent(name)}`, @@ -260,7 +260,7 @@ export async function deleteAgentConfigFile(agentId: string, name: string): Prom } export async function deleteAgentConfigSkill(agentId: string, name: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.delete( `/console/api/agent/${agentId}/config/skills/${encodeURIComponent(name)}`, @@ -272,7 +272,7 @@ export async function deleteAgentConfigSkill(agentId: string, name: string): Pro } export async function deleteAgentDriveFile(agentId: string, key: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const searchParams = new URLSearchParams({ key }) const response = await ctx.delete(`/console/api/agent/${agentId}/files?${searchParams}`) diff --git a/e2e/features/agent-v2/support/agent.ts b/e2e/features/agent-v2/support/agent.ts index 167b964bfed..dfcd407f754 100644 --- a/e2e/features/agent-v2/support/agent.ts +++ b/e2e/features/agent-v2/support/agent.ts @@ -1,11 +1,16 @@ import type { AgentAppComposerResponse, + AgentAppCreatePayload, AgentAppDetailWithSite, AgentReferencingWorkflowResponse, AgentReferencingWorkflowsResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { createApiContext, expectApiResponseOK } from '../../../support/api' +import { + zGetAgentByAgentIdResponse, + zPostAgentResponse, +} from '@dify/contracts/api/console/agent/zod.gen' +import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming' import { createPublishableAgentSoulConfig, @@ -13,21 +18,6 @@ import { normalAgentSoulConfig, } from './agent-soul' -export type AgentSeed = Pick< - AgentAppDetailWithSite, - | 'active_config_is_published' - | 'app_id' - | 'backing_app_id' - | 'description' - | 'enable_site' - | 'id' - | 'name' - | 'role' - | 'site' -> & { - active_config_snapshot_id?: string | null -} - export type CreateTestAgentOptions = { description?: string name?: string @@ -41,22 +31,23 @@ export async function createTestAgent({ description = 'Created by Dify E2E.', name = createE2EResourceName('Agent'), role = 'E2E test assistant', -}: CreateTestAgentOptions = {}): Promise { +}: CreateTestAgentOptions = {}): Promise { assertE2EResourceName(name, 'Agent') - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { + const data = { + description, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + role, + } satisfies AgentAppCreatePayload const response = await ctx.post('/console/api/agent', { - data: { - description, - icon: '🤖', - icon_background: '#FFEAD5', - icon_type: 'emoji', - name, - role, - }, + data, }) await expectApiResponseOK(response, 'Create Agent v2 test agent') - return (await response.json()) as AgentSeed + return zPostAgentResponse.parse(await response.json()) } finally { await ctx.dispose() } @@ -68,25 +59,25 @@ export async function createConfiguredTestAgent({ }: { agentSoul?: AgentSoulConfig seed?: CreateTestAgentOptions -} = {}): Promise { +} = {}): Promise { const agent = await createTestAgent(seed) await saveAgentComposerDraft(agent.id, agentSoul) return agent } -export async function getTestAgent(agentId: string): Promise { - const ctx = await createApiContext() +export async function getTestAgent(agentId: string): Promise { + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}`) await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`) - return (await response.json()) as AgentSeed + return zGetAgentByAgentIdResponse.parse(await response.json()) } finally { await ctx.dispose() } } export async function deleteTestAgent(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.delete(`/console/api/agent/${agentId}`) await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`) @@ -99,7 +90,7 @@ export async function saveAgentComposerDraft( agentId: string, agentSoul: AgentSoulConfig = defaultAgentSoulConfig, ): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.put(`/console/api/agent/${agentId}/composer`, { data: { @@ -118,7 +109,7 @@ export async function saveAgentComposerDraft( export async function getAgentReferencingWorkflows( agentId: string, ): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/referencing-workflows`) await expectApiResponseOK(response, `Get Agent v2 referencing workflows for ${agentId}`) @@ -130,7 +121,7 @@ export async function getAgentReferencingWorkflows( } export async function getAgentComposerDraft(agentId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agentId}/composer`) await expectApiResponseOK(response, `Get Agent v2 composer draft for ${agentId}`) @@ -150,7 +141,7 @@ export async function ensureAgentComposerDraftIsPublishable(agentId: string): Pr } export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post(`/console/api/agent/${agentId}/publish`, { data: { version_note: versionNote }, diff --git a/e2e/features/agent-v2/support/fixtures/access.ts b/e2e/features/agent-v2/support/fixtures/access.ts index 3dfb7ce414d..ba73df40c86 100644 --- a/e2e/features/agent-v2/support/fixtures/access.ts +++ b/e2e/features/agent-v2/support/fixtures/access.ts @@ -1,7 +1,10 @@ import type { AgentReferencingWorkflowsResponse } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import { + createConsoleApiContext, + expectApiResponseOK, +} from '../../../../support/api/console-context' import { requirePreseededAgent, requirePreseededWorkflow } from './agents' import { failFixturePrerequisite } from './common' @@ -14,7 +17,7 @@ export async function requirePreseededAgentWorkflowReference( const workflow = await requirePreseededWorkflow(world, workflowName) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/referencing-workflows`) await expectApiResponseOK(response, `Check preseeded Agent workflow reference ${agentName}`) diff --git a/e2e/features/agent-v2/support/fixtures/agents.ts b/e2e/features/agent-v2/support/fixtures/agents.ts index 3173177f86c..303f2e448b6 100644 --- a/e2e/features/agent-v2/support/fixtures/agents.ts +++ b/e2e/features/agent-v2/support/fixtures/agents.ts @@ -4,7 +4,10 @@ import type { } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import { + createConsoleApiContext, + expectApiResponseOK, +} from '../../../../support/api/console-context' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -124,7 +127,7 @@ export async function requirePreseededAgentDriveSkill( ): Promise { const agent = await requirePreseededAgent(world, agentName) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/drive/skills`) await expectApiResponseOK(response, `Check preseeded Agent skill ${skillName}`) @@ -169,7 +172,7 @@ export async function requirePreseededFullConfigAgentCoreConfiguration( agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) await expectApiResponseOK(response, `Check preseeded Agent core configuration ${agentName}`) @@ -246,7 +249,7 @@ export async function requirePreseededToolStatesAgentConfiguration( agentBuilderPreseededResources.tavilySearchTool, ) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) await expectApiResponseOK(response, `Check preseeded Agent tool states ${agentName}`) @@ -320,7 +323,7 @@ export async function requirePreseededDualRetrievalAgentConfiguration( agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) await expectApiResponseOK(response, `Check preseeded Agent dual retrieval ${agentName}`) diff --git a/e2e/features/agent-v2/support/fixtures/common.ts b/e2e/features/agent-v2/support/fixtures/common.ts index a73bcf841e4..802b7bf5ac3 100644 --- a/e2e/features/agent-v2/support/fixtures/common.ts +++ b/e2e/features/agent-v2/support/fixtures/common.ts @@ -1,5 +1,8 @@ import type { DifyWorld } from '../../../support/world' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import { + createConsoleApiContext, + expectApiResponseOK, +} from '../../../../support/api/console-context' export type PreseededResource = NonNullable< DifyWorld['agentBuilder']['fixtures']['preseededResources'][string] @@ -45,7 +48,7 @@ export const findConsoleResourceByName = async { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(path) await expectApiResponseOK(response, action) diff --git a/e2e/features/agent-v2/support/fixtures/datasets.ts b/e2e/features/agent-v2/support/fixtures/datasets.ts index c06aa24f9bf..c7bc58601fb 100644 --- a/e2e/features/agent-v2/support/fixtures/datasets.ts +++ b/e2e/features/agent-v2/support/fixtures/datasets.ts @@ -6,7 +6,10 @@ import type { } from '@dify/contracts/api/console/datasets/types.gen' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import { + createConsoleApiContext, + expectApiResponseOK, +} from '../../../../support/api/console-context' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -34,7 +37,7 @@ export const getPreseededDataset = async (resourceName: string) => { } const getDatasetIndexingStatuses = async (datasetId: string, resourceName: string) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) await expectApiResponseOK(response, `Check preseeded dataset indexing status ${resourceName}`) @@ -48,7 +51,7 @@ const getDatasetIndexingStatuses = async (datasetId: string, resourceName: strin const getDatasetDocuments = async (datasetId: string, resourceName: string) => { const documents: DocumentWithSegmentsListResponse['data'] = [] - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { let page = 1 let hasMore = true @@ -76,7 +79,7 @@ const datasetHasEnabledSegmentContainingTokens = async ( expectedTokens: string[], ) => { const documents = await getDatasetDocuments(datasetId, resourceName) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { for (const document of documents) { const query = buildQuery({ diff --git a/e2e/features/agent-v2/support/fixtures/models.ts b/e2e/features/agent-v2/support/fixtures/models.ts index 1c4d210a423..d1acc1f2ec2 100644 --- a/e2e/features/agent-v2/support/fixtures/models.ts +++ b/e2e/features/agent-v2/support/fixtures/models.ts @@ -3,7 +3,10 @@ import type { ProviderWithModelsResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { DifyWorld } from '../../../support/world' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import { + createConsoleApiContext, + expectApiResponseOK, +} from '../../../../support/api/console-context' import { agentBuilderPreseededResources } from '../agent-builder-resources' import { failFixturePrerequisite } from './common' @@ -82,7 +85,7 @@ async function requireAgentBuilderModel( ): Promise> { if (!config.ok) return failFixturePrerequisite(world, config.reason) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get( `/console/api/workspaces/current/models/model-types/${config.type}`, @@ -132,7 +135,7 @@ export async function requireAgentBuilderStableChatModel( export async function requireAgentBuilderSpeechToTextModel( world: DifyWorld, ): Promise> { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() let defaultModel: NonNullable try { diff --git a/e2e/features/agent-v2/support/fixtures/tools.ts b/e2e/features/agent-v2/support/fixtures/tools.ts index e5639dcf40d..b8be21c04ec 100644 --- a/e2e/features/agent-v2/support/fixtures/tools.ts +++ b/e2e/features/agent-v2/support/fixtures/tools.ts @@ -1,6 +1,9 @@ import type { DifyWorld } from '../../../support/world' import type { LocalizedLabel, PreseededResource } from './common' -import { createApiContext, expectApiResponseOK } from '../../../../support/api' +import { + createConsoleApiContext, + expectApiResponseOK, +} from '../../../../support/api/console-context' import { asRecord, asString, failFixturePrerequisite, matchesNameOrLabel } from './common' type BuiltinToolProvider = { @@ -93,7 +96,7 @@ export async function requirePreseededTool( const parsed = splitToolDisplayName(resourceName) if (!parsed.ok) return failFixturePrerequisite(world, parsed.reason) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get('/console/api/workspaces/current/tools/builtin') await expectApiResponseOK(response, `Check preseeded tool ${resourceName}`) diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index 45bca4df042..08c592e8166 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -18,13 +18,9 @@ import type { import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed' import type { UploadedConsoleFile } from './agent-drive' import { readFile } from 'node:fs/promises' -import { - createApiContext, - createTestApp, - expectApiResponseOK, - publishWorkflowApp, - syncAgentV2WorkflowDraft, -} from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' +import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' +import { publishWorkflowApp } from '../../../support/api/workflows' import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins' import { sleep } from '../../../support/process' import { blocked, created, skipped, updated, verified } from '../../../support/seed' @@ -53,6 +49,7 @@ import { } from './fixtures/common' import { splitToolDisplayName } from './fixtures/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials' +import { syncAgentV2WorkflowDraft } from './workflow' type StableModel = { name: string @@ -127,7 +124,7 @@ const parseJsonEnv = (envName: string) => { } const findModel = async (config: StableModel, title: string) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get( `/console/api/workspaces/current/models/model-types/${config.type}`, @@ -156,7 +153,7 @@ const findModel = async (config: StableModel, title: string) => { } const resolveProvider = async (config: StableModel) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get( `/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`, @@ -178,7 +175,7 @@ const resolveProvider = async (config: StableModel) => { } const selectCustomProviderCredential = async (provider: string, credentialId?: string) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { if (credentialId) { const switchResponse = await ctx.post( @@ -210,7 +207,7 @@ const upsertStableProviderCredential = async ( credentials: Record, credentialId?: string, ) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { if (credentialId) { const updateResponse = await ctx.put( @@ -336,7 +333,7 @@ const seedAgentDecisionModel = async (context: SeedContext) => }) const getDefaultModel = async (modelType: string) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get( `/console/api/workspaces/current/default-model?${buildQuery({ model_type: modelType })}`, @@ -350,7 +347,7 @@ const getDefaultModel = async (modelType: string) => { } const setDefaultModel = async (model: StableModel) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post('/console/api/workspaces/current/default-model', { data: { @@ -431,7 +428,7 @@ const findBuiltinTool = async (displayName: string) => { const parsed = splitToolDisplayName(displayName) if (!parsed.ok) return { ok: false as const, reason: parsed.reason } - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get('/console/api/workspaces/current/tools/builtin') await expectApiResponseOK(response, `Check built-in tool ${displayName}`) @@ -476,7 +473,7 @@ const uploadConsoleFile = async ( fileName: string, filePath: string, ): Promise => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post('/console/api/files/upload', { multipart: { @@ -504,7 +501,7 @@ const findDataset = (name: string) => { } const getDatasetDocuments = async (datasetId: string) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get( `/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`, @@ -525,7 +522,7 @@ const requiredKnowledgeSegmentTokens = [ const datasetHasKnowledgeSegment = async (datasetId: string) => { const documents = await getDatasetDocuments(datasetId) - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { for (const document of documents) { const response = await ctx.get( @@ -563,7 +560,7 @@ const waitForDatasetCompleted = async (datasetId: string) => { let status = 'missing' while (Date.now() < deadline) { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) await expectApiResponseOK(response, `Check dataset indexing ${datasetId}`) @@ -614,7 +611,7 @@ const addKnowledgeDocument = async (datasetId: string) => { }, } satisfies KnowledgeConfig - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post(`/console/api/datasets/${datasetId}/documents`, { data: body }) await expectApiResponseOK(response, `Seed knowledge document ${datasetId}`) @@ -624,7 +621,7 @@ const addKnowledgeDocument = async (datasetId: string) => { } const createDataset = async (name: string) => { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.post('/console/api/datasets', { data: { diff --git a/e2e/features/agent-v2/support/workflow.ts b/e2e/features/agent-v2/support/workflow.ts new file mode 100644 index 00000000000..54605223b05 --- /dev/null +++ b/e2e/features/agent-v2/support/workflow.ts @@ -0,0 +1,72 @@ +import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' +import { zPostAppsByAppIdWorkflowsDraftResponse } from '@dify/contracts/api/console/apps/zod.gen' +import * as z from 'zod' +import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' +import { getWorkflowDraft } from '../../../support/api/workflows' + +const agentV2WorkflowNodeId = 'agent-v2' +const zWorkflowGraph = z.object({ + nodes: z.array( + z.object({ + data: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + }), + ), +}) + +export async function getAgentV2WorkflowNodeData(appId: string) { + const draft = await getWorkflowDraft(appId) + const graph = zWorkflowGraph.parse(draft.graph) + const agentNode = graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) + if (!agentNode) + throw new Error( + `Workflow draft ${appId} does not include Agent v2 node ${agentV2WorkflowNodeId}.`, + ) + + return agentNode.data ?? {} +} + +export async function syncAgentV2WorkflowDraft(appId: string, agentId: string): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'agent-v2', + type: 'custom', + position: { x: 420, y: 282 }, + data: { + id: 'agent-v2', + type: 'agent', + title: 'Agent', + desc: '', + agent_binding: { + binding_type: 'roster_agent', + agent_id: agentId, + }, + agent_node_kind: 'dify_agent', + version: '2', + }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) + await expectApiResponseOK(response, `Sync Agent v2 workflow draft for ${appId}`) + zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} diff --git a/e2e/features/agent-v2/tools.feature b/e2e/features/agent-v2/tools.feature index 42e3cb975c2..c4d3d286ebe 100644 --- a/e2e/features/agent-v2/tools.feature +++ b/e2e/features/agent-v2/tools.feature @@ -28,11 +28,11 @@ Feature: Agent v2 tools Then the Agent v2 Backend service API response should include the JSON Replace E2E marker @core - Scenario: Tool selector shows an empty state for a missing tool search + Scenario: Tool selector recovers after an unavailable installed-tool search Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API When I open the Agent v2 configure page And I search for the missing Agent v2 tool from the Tools selector - Then I should see the Agent v2 tool selector empty state + Then I should see the unavailable Agent v2 installed-tool search applied When I clear the Agent v2 tool selector search Then I should see the Agent v2 tool selector ready for another search diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 61858c02dfb..e3ba96d1635 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -2,7 +2,7 @@ import type { DifyWorld } from '../../support/world' import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { setAgentApiAccess, setAgentSiteAccessAndGetURL } from '../../agent-v2/support/access-point' +import { setAgentApiAccess, setAgentSiteAccess } from '../../agent-v2/support/access-point' import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, @@ -19,10 +19,7 @@ Given( /^Agent v2 (Web app|Backend service API) access has been enabled via API$/, async function (this: DifyWorld, surface: AccessSurfaceName) { if (surface === 'Web app') { - this.agentBuilder.accessPoint.webAppURL = await setAgentSiteAccessAndGetURL( - getCurrentAgentId(this), - true, - ) + await setAgentSiteAccess(getCurrentAgentId(this), true) return } diff --git a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts index 79819b1af22..ead1abf1bcd 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -2,10 +2,11 @@ import type { DataTable } from '@cucumber/cucumber' import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' import type { AgentV2WorkflowOutputVariable, DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zDeclaredOutputConfig } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' -import { getWorkflowDraft } from '../../../support/api' +import * as z from 'zod' +import { getAgentV2WorkflowNodeData } from '../../agent-v2/support/workflow' -const agentV2WorkflowNodeId = 'agent-v2' const taskOutputName = 'e2e_report' const renamedTaskOutputName = 'e2e_final_report' @@ -18,23 +19,12 @@ const getCurrentAppId = (world: DifyWorld) => { return appId } -const getAgentV2WorkflowNodeData = async (appId: string) => { - const draft = await getWorkflowDraft(appId) - const agentNode = draft.graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) - if (!agentNode) - throw new Error( - `Workflow draft ${appId} does not include Agent v2 node ${agentV2WorkflowNodeId}.`, - ) - - return agentNode.data ?? {} -} +const parseDeclaredOutputs = (value: unknown): DeclaredOutputConfig[] => + z.array(zDeclaredOutputConfig).optional().default([]).parse(value) const getDeclaredOutputsFromDraft = async (appId: string): Promise => { const data = await getAgentV2WorkflowNodeData(appId) - const outputs = data.agent_declared_outputs - if (!Array.isArray(outputs)) return [] - - return outputs as DeclaredOutputConfig[] + return parseDeclaredOutputs(data.agent_declared_outputs) } const getOutputVariablesFromDraft = async (appId: string) => getDeclaredOutputsFromDraft(appId) @@ -310,9 +300,7 @@ async function expectAgentTaskOutputReference( .poll( async () => { const data = await getAgentV2WorkflowNodeData(appId) - const outputs = Array.isArray(data.agent_declared_outputs) - ? (data.agent_declared_outputs as DeclaredOutputConfig[]) - : [] + const outputs = parseDeclaredOutputs(data.agent_declared_outputs) const expectedOutput = outputs.find((output) => output.name === expectedName) return { diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index 6219beb50be..bdd5c26e4e5 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -249,15 +249,16 @@ Then( }, ) -Then('I should see the Agent v2 tool selector empty state', async function (this: DifyWorld) { - const page = this.getPage() +Then( + 'I should see the unavailable Agent v2 installed-tool search applied', + async function (this: DifyWorld) { + const page = this.getPage() + const search = getToolSelectorSearch(this) - await expect(page.getByText('No integrations were found')).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('link', { name: 'Requests to the community' })).toBeVisible() - await expect( - page.getByText(agentBuilderFixedInputs.missingToolSearchWithSuffix), - ).not.toBeVisible() -}) + await expect(search).toHaveValue(agentBuilderFixedInputs.missingToolSearchWithSuffix) + await expect(page.getByText('All tools', { exact: true })).not.toBeVisible() + }, +) Then( 'I should see the Agent v2 tool selector ready for another search', diff --git a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts index c2fc6fda145..36bb0fa0db2 100644 --- a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts +++ b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts @@ -1,7 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createTestApp, syncAgentV2WorkflowDraft } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' import { createConfiguredTestAgent, publishAgent } from '../../agent-v2/support/agent' import { @@ -9,6 +9,7 @@ import { normalAgentPrompt, normalAgentSoulConfig, } from '../../agent-v2/support/agent-soul' +import { syncAgentV2WorkflowDraft } from '../../agent-v2/support/workflow' Given( 'a workflow app with an Agent v2 node has been created via API', diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index 8e976d78cf4..989defab4ce 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -1,7 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createTestApp } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index d34e227ace0..b06d63942b5 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -1,12 +1,9 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createTestApp, - enableAppSiteAndGetURL, - publishWorkflowApp, - syncRunnableWorkflowDraft, -} from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' +import { enableAppSiteAndGetURL } from '../../../support/api/web-apps' +import { publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts index 20f513f471b..0ea4c980f5d 100644 --- a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -1,7 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createTestApp } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' Given( diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts index aee5962b98c..dc4baafee42 100644 --- a/e2e/features/step-definitions/apps/web-app-service.steps.ts +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -1,13 +1,9 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createTestApp, - getAppSiteDetail, - getAppSiteURL, - publishWorkflowApp, - syncRunnableWorkflowDraft, -} from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' +import { getAppSiteDetail, getAppSiteURL } from '../../../support/api/web-apps' +import { publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' import { baseURL, defaultLocale } from '../../../test-env' diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts index d9cf64bb9c3..76e0aa2d369 100644 --- a/e2e/features/step-definitions/apps/workflow-run.steps.ts +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -1,7 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { syncRunnableWorkflowDraft } from '../../../support/api' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' Given('a minimal runnable workflow draft has been synced', async function (this: DifyWorld) { const appId = this.createdAppIds.at(-1) diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index a3399130f4e..fedec7a3944 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -1,12 +1,15 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { zCreateAppPayload } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' -import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api' +import { createTestApp } from '../../../support/api/apps' +import { syncMinimalWorkflowDraft } from '../../../support/api/workflows' import { waitForAppsConsole } from '../../../support/apps' import { createE2EResourceName } from '../../../support/naming' Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { - const app = await createTestApp(createE2EResourceName('App', mode), mode) + const appMode = zCreateAppPayload.shape.mode.parse(mode) + const app = await createTestApp(createE2EResourceName('App', appMode), appMode) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 40c794c6b14..9155684a7a3 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -8,11 +8,11 @@ import { fileURLToPath } from 'node:url' import { After, AfterAll, Before, setDefaultTimeout, Status } from '@cucumber/cucumber' import { chromium, webkit } from '@playwright/test' import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' -import { deleteTestApp } from '../../support/api' +import { deleteTestApp } from '../../support/api/apps' +import { deleteTestDataset } from '../../support/api/datasets' +import { deleteBuiltinToolCredential } from '../../support/api/tools' import { runCleanupTasks, shouldFailForCleanupErrors } from '../../support/cleanup' -import { deleteTestDataset } from '../../support/datasets' import { getVoiceInputTestMaterialPath } from '../../support/test-materials' -import { deleteBuiltinToolCredential } from '../../support/tools' import { baseURL, cucumberHeadless, cucumberSlowMo, e2eBrowser } from '../../test-env' import { deleteTestAgent } from '../agent-v2/support/agent' import { diff --git a/e2e/support/api.ts b/e2e/support/api.ts deleted file mode 100644 index 3cc4175debc..00000000000 --- a/e2e/support/api.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' -import type { APIResponse } from '@playwright/test' -import { readFile } from 'node:fs/promises' -import { zAppDetailWithSite } from '@dify/contracts/api/console/apps/zod.gen' -import { request } from '@playwright/test' -import { authStatePath } from '../fixtures/auth' -import { apiURL } from '../test-env' -import { assertE2EResourceName, createE2EResourceName } from './naming' - -type StorageState = { - cookies: Array<{ name: string; value: string }> -} - -export async function createApiContext() { - const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState - const csrfToken = state.cookies.find((c) => c.name.endsWith('csrf_token'))?.value ?? '' - - return request.newContext({ - baseURL: apiURL, - extraHTTPHeaders: { 'X-CSRF-Token': csrfToken }, - storageState: authStatePath, - }) -} - -export async function expectApiResponseOK(response: APIResponse, action: string): Promise { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`) -} - -export type AppSeed = { - id: string - name: string -} - -export type WorkflowDraft = { - graph: { - edges: Array> - nodes: Array<{ - data?: Record - id: string - type: string - }> - viewport?: Record - } -} - -export async function createTestApp( - name = createE2EResourceName('App'), - mode = 'workflow', -): Promise { - assertE2EResourceName(name, 'App') - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/apps', { - data: { - name, - mode, - icon_type: 'emoji', - icon: '🤖', - icon_background: '#FFEAD5', - }, - }) - await expectApiResponseOK(response, `Create ${mode} app ${name}`) - const body = (await response.json()) as AppSeed - return body - } finally { - await ctx.dispose() - } -} - -export async function getWorkflowDraft(appId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}/workflows/draft`) - await expectApiResponseOK(response, `Get workflow draft for ${appId}`) - return (await response.json()) as WorkflowDraft - } finally { - await ctx.dispose() - } -} - -export async function syncMinimalWorkflowDraft(appId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { - data: { - graph: { - nodes: [ - { - id: '1', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: '1', type: 'start', title: 'Start', variables: [] }, - }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - }, - }) - await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`) - } finally { - await ctx.dispose() - } -} - -export async function syncAgentV2WorkflowDraft(appId: string, agentId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { - data: { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { - id: 'agent-v2', - type: 'custom', - position: { x: 420, y: 282 }, - data: { - id: 'agent-v2', - type: 'agent', - title: 'Agent', - desc: '', - agent_binding: { - binding_type: 'roster_agent', - agent_id: agentId, - }, - agent_node_kind: 'dify_agent', - version: '2', - }, - }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - }, - }) - await expectApiResponseOK(response, `Sync Agent v2 workflow draft for ${appId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteTestApp(id: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.delete(`/console/api/apps/${id}`) - await expectApiResponseOK(response, `Delete app ${id}`) - } finally { - await ctx.dispose() - } -} - -export async function syncRunnableWorkflowDraft(appId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { - data: { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { - id: 'end', - type: 'custom', - position: { x: 480, y: 282 }, - data: { - id: 'end', - type: 'end', - title: 'End', - outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], - }, - }, - ], - edges: [ - { - id: 'start-end', - type: 'custom', - source: 'start', - target: 'end', - sourceHandle: 'source', - targetHandle: 'target', - }, - ], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - }, - }) - await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`) - } finally { - await ctx.dispose() - } -} - -export async function publishWorkflowApp(appId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { - data: { marked_name: '', marked_comment: '' }, - }) - await expectApiResponseOK(response, `Publish workflow app ${appId}`) - } finally { - await ctx.dispose() - } -} - -export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { - if (!site?.app_base_url || !site.access_token) - throw new Error('App detail does not include a Web App URL.') - - const webAppMode = (() => { - if (mode === 'completion' || mode === 'workflow') return mode - if (mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'chat') return 'chat' - throw new Error(`Unsupported Web App mode: ${mode}`) - })() - - return `${site.app_base_url}/${webAppMode}/${site.access_token}` -} - -export async function getAppSiteDetail(appId: string): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}`) - await expectApiResponseOK(response, `Get app site detail for ${appId}`) - return zAppDetailWithSite.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function enableAppSiteAndGetURL(appId: string): Promise { - return getAppSiteURL(await setAppSiteEnabled(appId, true)) -} - -export async function setAppSiteEnabled( - appId: string, - enabled: boolean, -): Promise { - const ctx = await createApiContext() - try { - const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, { - data: { enable_site: enabled }, - }) - await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) - } finally { - await ctx.dispose() - } - - return getAppSiteDetail(appId) -} diff --git a/e2e/support/api/apps.ts b/e2e/support/api/apps.ts new file mode 100644 index 00000000000..ae7ae30ffaf --- /dev/null +++ b/e2e/support/api/apps.ts @@ -0,0 +1,36 @@ +import type { CreateAppPayload, PostAppsResponse } from '@dify/contracts/api/console/apps/types.gen' +import { zPostAppsResponse } from '@dify/contracts/api/console/apps/zod.gen' +import { assertE2EResourceName, createE2EResourceName } from '../naming' +import { createConsoleApiContext, expectApiResponseOK } from './console-context' + +export async function createTestApp( + name = createE2EResourceName('App'), + mode: CreateAppPayload['mode'] = 'workflow', +): Promise { + assertE2EResourceName(name, 'App') + const ctx = await createConsoleApiContext() + try { + const data = { + name, + mode, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + } satisfies CreateAppPayload + const response = await ctx.post('/console/api/apps', { data }) + await expectApiResponseOK(response, `Create ${mode} app ${name}`) + return zPostAppsResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function deleteTestApp(id: string): Promise { + const ctx = await createConsoleApiContext() + try { + const response = await ctx.delete(`/console/api/apps/${id}`) + await expectApiResponseOK(response, `Delete app ${id}`) + } finally { + await ctx.dispose() + } +} diff --git a/e2e/support/api/console-context.ts b/e2e/support/api/console-context.ts new file mode 100644 index 00000000000..6ba5c4c33df --- /dev/null +++ b/e2e/support/api/console-context.ts @@ -0,0 +1,34 @@ +import type { APIResponse } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { request } from '@playwright/test' +import * as z from 'zod' +import { authStatePath } from '../../fixtures/auth' +import { apiURL } from '../../test-env' + +const zStorageState = z.object({ + cookies: z.array( + z.object({ + name: z.string(), + value: z.string(), + }), + ), +}) + +export async function createConsoleApiContext() { + const state = zStorageState.parse(JSON.parse(await readFile(authStatePath, 'utf8'))) + const csrfToken = state.cookies.find((cookie) => cookie.name.endsWith('csrf_token'))?.value + if (!csrfToken) throw new Error(`No CSRF token found in E2E auth state: ${authStatePath}`) + + return request.newContext({ + baseURL: apiURL, + extraHTTPHeaders: { 'X-CSRF-Token': csrfToken }, + storageState: authStatePath, + }) +} + +export async function expectApiResponseOK(response: APIResponse, action: string): Promise { + if (response.ok()) return + + const body = await response.text().catch(() => '') + throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`) +} diff --git a/e2e/support/datasets.ts b/e2e/support/api/datasets.ts similarity index 68% rename from e2e/support/datasets.ts rename to e2e/support/api/datasets.ts index 196b335af32..9a696a24d8b 100644 --- a/e2e/support/datasets.ts +++ b/e2e/support/api/datasets.ts @@ -1,7 +1,7 @@ -import { createApiContext, expectApiResponseOK } from './api' +import { createConsoleApiContext, expectApiResponseOK } from './console-context' export async function deleteTestDataset(datasetId: string): Promise { - const ctx = await createApiContext() + const ctx = await createConsoleApiContext() try { const response = await ctx.delete(`/console/api/datasets/${datasetId}`) await expectApiResponseOK(response, `Delete dataset ${datasetId}`) diff --git a/e2e/support/api/marketplace-plugins.ts b/e2e/support/api/marketplace-plugins.ts new file mode 100644 index 00000000000..1dea0c7d95c --- /dev/null +++ b/e2e/support/api/marketplace-plugins.ts @@ -0,0 +1,122 @@ +import type { + GetWorkspacesCurrentPluginTasksByTaskIdResponse, + ParserLatest, + ParserPluginIdentifiers, + PostWorkspacesCurrentPluginInstallMarketplaceResponse, + PostWorkspacesCurrentPluginInstallPkgResponse, + PostWorkspacesCurrentPluginListInstallationsIdsResponse, + PostWorkspacesCurrentPluginListLatestVersionsResponse, + PostWorkspacesCurrentPluginUploadPkgResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' +import type { Buffer } from 'node:buffer' +import { + zGetWorkspacesCurrentPluginTasksByTaskIdResponse, + zPostWorkspacesCurrentPluginInstallMarketplaceResponse, + zPostWorkspacesCurrentPluginInstallPkgResponse, + zPostWorkspacesCurrentPluginListInstallationsIdsResponse, + zPostWorkspacesCurrentPluginListLatestVersionsResponse, + zPostWorkspacesCurrentPluginUploadPkgResponse, +} from '@dify/contracts/api/console/workspaces/zod.gen' +import { createConsoleApiContext, expectApiResponseOK } from './console-context' + +export async function getLatestMarketplacePluginVersions( + pluginIds: string[], +): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { plugin_ids: pluginIds } satisfies ParserLatest + const response = await ctx.post('/console/api/workspaces/current/plugin/list/latest-versions', { + data, + }) + await expectApiResponseOK(response, 'Resolve latest marketplace plugin versions') + return zPostWorkspacesCurrentPluginListLatestVersionsResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function getInstalledMarketplacePlugins( + pluginIds: string[], +): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { plugin_ids: pluginIds } satisfies ParserLatest + const response = await ctx.post( + '/console/api/workspaces/current/plugin/list/installations/ids', + { data }, + ) + await expectApiResponseOK(response, 'List installed marketplace plugins') + return zPostWorkspacesCurrentPluginListInstallationsIdsResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function getMarketplacePluginInstallTask( + taskId: string, +): Promise { + const ctx = await createConsoleApiContext() + try { + const response = await ctx.get(`/console/api/workspaces/current/plugin/tasks/${taskId}`) + await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) + return zGetWorkspacesCurrentPluginTasksByTaskIdResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function startMarketplacePluginInstall( + pluginUniqueIdentifiers: string[], +): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { + plugin_unique_identifiers: pluginUniqueIdentifiers, + } satisfies ParserPluginIdentifiers + const response = await ctx.post('/console/api/workspaces/current/plugin/install/marketplace', { + data, + }) + await expectApiResponseOK(response, 'Install marketplace plugins') + return zPostWorkspacesCurrentPluginInstallMarketplaceResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function uploadMarketplacePluginPackageFile( + pkg: Buffer, + fileName: string, +): Promise { + const ctx = await createConsoleApiContext() + try { + const response = await ctx.post('/console/api/workspaces/current/plugin/upload/pkg', { + multipart: { + pkg: { + buffer: pkg, + mimeType: 'application/octet-stream', + name: fileName, + }, + }, + }) + await expectApiResponseOK(response, `Upload marketplace package ${fileName}`) + return zPostWorkspacesCurrentPluginUploadPkgResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function startUploadedPluginPackageInstall( + pluginUniqueIdentifiers: string[], +): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { + plugin_unique_identifiers: pluginUniqueIdentifiers, + } satisfies ParserPluginIdentifiers + const response = await ctx.post('/console/api/workspaces/current/plugin/install/pkg', { data }) + await expectApiResponseOK(response, 'Install uploaded plugin packages') + return zPostWorkspacesCurrentPluginInstallPkgResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} diff --git a/e2e/support/api/tools.ts b/e2e/support/api/tools.ts new file mode 100644 index 00000000000..10db3d50713 --- /dev/null +++ b/e2e/support/api/tools.ts @@ -0,0 +1,24 @@ +import type { BuiltinToolCredentialDeletePayload } from '@dify/contracts/api/console/workspaces/types.gen' +import { zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse } from '@dify/contracts/api/console/workspaces/zod.gen' +import { createConsoleApiContext, expectApiResponseOK } from './console-context' + +export async function deleteBuiltinToolCredential( + provider: string, + credentialId: string, +): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { credential_id: credentialId } satisfies BuiltinToolCredentialDeletePayload + const response = await ctx.post( + `/console/api/workspaces/current/tool-provider/builtin/${provider}/delete`, + { data }, + ) + await expectApiResponseOK( + response, + `Delete built-in tool credential ${credentialId} for ${provider}`, + ) + zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} diff --git a/e2e/support/api/web-apps.ts b/e2e/support/api/web-apps.ts new file mode 100644 index 00000000000..c1f557285dd --- /dev/null +++ b/e2e/support/api/web-apps.ts @@ -0,0 +1,47 @@ +import type { + AppDetailWithSite, + AppSiteStatusPayload, + GetAppsByAppIdResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import { zGetAppsByAppIdResponse } from '@dify/contracts/api/console/apps/zod.gen' +import { createConsoleApiContext, expectApiResponseOK } from './console-context' + +export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { + if (!site?.app_base_url || !site.access_token) + throw new Error('App detail does not include a Web App URL.') + + const webAppMode = (() => { + if (mode === 'completion' || mode === 'workflow') return mode + if (mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'chat') return 'chat' + throw new Error(`Unsupported Web App mode: ${mode}`) + })() + + return `${site.app_base_url}/${webAppMode}/${site.access_token}` +} + +export async function getAppSiteDetail(appId: string): Promise { + const ctx = await createConsoleApiContext() + try { + const response = await ctx.get(`/console/api/apps/${appId}`) + await expectApiResponseOK(response, `Get app site detail for ${appId}`) + return zGetAppsByAppIdResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function enableAppSiteAndGetURL(appId: string): Promise { + await setAppSiteEnabled(appId, true) + return getAppSiteURL(await getAppSiteDetail(appId)) +} + +export async function setAppSiteEnabled(appId: string, enabled: boolean): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { enable_site: enabled } satisfies AppSiteStatusPayload + const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, { data }) + await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) + } finally { + await ctx.dispose() + } +} diff --git a/e2e/support/api/workflows.ts b/e2e/support/api/workflows.ts new file mode 100644 index 00000000000..cc826c508fb --- /dev/null +++ b/e2e/support/api/workflows.ts @@ -0,0 +1,115 @@ +import type { + GetAppsByAppIdWorkflowsDraftResponse, + PublishWorkflowPayload, + SyncDraftWorkflowPayload, +} from '@dify/contracts/api/console/apps/types.gen' +import { + zGetAppsByAppIdWorkflowsDraftResponse, + zPostAppsByAppIdWorkflowsDraftResponse, + zPostAppsByAppIdWorkflowsPublishResponse, +} from '@dify/contracts/api/console/apps/zod.gen' +import { createConsoleApiContext, expectApiResponseOK } from './console-context' + +export async function getWorkflowDraft( + appId: string, +): Promise { + const ctx = await createConsoleApiContext() + try { + const response = await ctx.get(`/console/api/apps/${appId}/workflows/draft`) + await expectApiResponseOK(response, `Get workflow draft for ${appId}`) + return zGetAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function syncMinimalWorkflowDraft(appId: string): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { + graph: { + nodes: [ + { + id: '1', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: '1', type: 'start', title: 'Start', variables: [] }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) + await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`) + zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function syncRunnableWorkflowDraft(appId: string): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'end', + type: 'custom', + position: { x: 480, y: 282 }, + data: { + id: 'end', + type: 'end', + title: 'End', + outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], + }, + }, + ], + edges: [ + { + id: 'start-end', + type: 'custom', + source: 'start', + target: 'end', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) + await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`) + zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} + +export async function publishWorkflowApp(appId: string): Promise { + const ctx = await createConsoleApiContext() + try { + const data = { + marked_name: '', + marked_comment: '', + } satisfies PublishWorkflowPayload + const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { data }) + await expectApiResponseOK(response, `Publish workflow app ${appId}`) + zPostAppsByAppIdWorkflowsPublishResponse.parse(await response.json()) + } finally { + await ctx.dispose() + } +} diff --git a/e2e/support/marketplace-plugins.ts b/e2e/support/marketplace-plugins.ts index e5d1b25cd64..4932bf14eb6 100644 --- a/e2e/support/marketplace-plugins.ts +++ b/e2e/support/marketplace-plugins.ts @@ -1,36 +1,17 @@ +import type { PluginInstallTask } from '@dify/contracts/api/console/workspaces/types.gen' import type { SeedContext, SeedResult } from './seed' import { Buffer } from 'node:buffer' -import { createApiContext, expectApiResponseOK } from './api' +import { + getInstalledMarketplacePlugins, + getLatestMarketplacePluginVersions, + getMarketplacePluginInstallTask, + startMarketplacePluginInstall, + startUploadedPluginPackageInstall, + uploadMarketplacePluginPackageFile, +} from './api/marketplace-plugins' import { sleep } from './process' import { blocked, created, skipped, verified } from './seed' -type LatestPlugin = { - unique_identifier?: string - version?: string -} - -type PluginInstallation = { - plugin_id: string - plugin_unique_identifier: string -} - -type PluginInstallTask = { - id?: string - plugins?: Array<{ - message?: string - plugin_id?: string - plugin_unique_identifier?: string - status?: string - }> - status?: string -} - -type PluginInstallStartResponse = { - all_installed?: boolean - task?: PluginInstallTask | null - task_id?: string -} - type MarketplacePluginBootstrapConfig = { defaultPluginIds: string[] pluginIdsEnv: string @@ -56,45 +37,23 @@ const getPluginId = (pluginUniqueIdentifier: string) => const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { if (pluginIds.length === 0) return { identifiers: [] as string[], missing: [] as string[] } - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/list/latest-versions', { - data: { plugin_ids: pluginIds }, - }) - await expectApiResponseOK(response, 'Resolve latest marketplace plugin versions') - const body = (await response.json()) as { versions?: Record } - const identifiers: string[] = [] - const missing: string[] = [] + const body = await getLatestMarketplacePluginVersions(pluginIds) + const identifiers: string[] = [] + const missing: string[] = [] - for (const pluginId of pluginIds) { - const latest = body.versions?.[pluginId] - if (latest?.unique_identifier) identifiers.push(latest.unique_identifier) - else missing.push(pluginId) - } - - return { identifiers, missing } - } finally { - await ctx.dispose() + for (const pluginId of pluginIds) { + const latest = body.versions[pluginId] + if (latest?.unique_identifier) identifiers.push(latest.unique_identifier) + else missing.push(pluginId) } + + return { identifiers, missing } } const listInstalledPlugins = async (pluginIds: string[]) => { - if (pluginIds.length === 0) return [] as PluginInstallation[] + if (pluginIds.length === 0) return [] - const ctx = await createApiContext() - try { - const response = await ctx.post( - '/console/api/workspaces/current/plugin/list/installations/ids', - { - data: { plugin_ids: pluginIds }, - }, - ) - await expectApiResponseOK(response, 'List installed marketplace plugins') - const body = (await response.json()) as { plugins?: PluginInstallation[] } - return body.plugins ?? [] - } finally { - await ctx.dispose() - } + return (await getInstalledMarketplacePlugins(pluginIds)).plugins } const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => { @@ -102,15 +61,7 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => let lastTask: PluginInstallTask | undefined while (Date.now() < deadline) { - const ctx = await createApiContext() - try { - const response = await ctx.get(`/console/api/workspaces/current/plugin/tasks/${taskId}`) - await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) - const body = (await response.json()) as { task?: PluginInstallTask } - lastTask = body.task - } finally { - await ctx.dispose() - } + lastTask = (await getMarketplacePluginInstallTask(taskId)).task if (lastTask?.status === terminalSuccessTaskStatus) return { ok: true as const, task: lastTask } @@ -140,16 +91,7 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => } const installMarketplacePlugins = async (pluginUniqueIdentifiers: string[]) => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/install/marketplace', { - data: { plugin_unique_identifiers: pluginUniqueIdentifiers }, - }) - await expectApiResponseOK(response, 'Install marketplace plugins') - return (await response.json()) as PluginInstallStartResponse - } finally { - await ctx.dispose() - } + return startMarketplacePluginInstall(pluginUniqueIdentifiers) } const getMarketplaceDownloadUrl = (pluginUniqueIdentifier: string) => { @@ -174,44 +116,12 @@ const downloadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) const uploadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => { const pkg = await downloadMarketplacePluginPackage(pluginUniqueIdentifier) - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/upload/pkg', { - multipart: { - pkg: { - buffer: pkg, - mimeType: 'application/octet-stream', - name: `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg`, - }, - }, - }) - await expectApiResponseOK( - response, - `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)}`, - ) - const body = (await response.json()) as { unique_identifier?: string } - if (!body.unique_identifier) - throw new Error( - `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)} did not return a unique identifier.`, - ) - - return body.unique_identifier - } finally { - await ctx.dispose() - } + const fileName = `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg` + return (await uploadMarketplacePluginPackageFile(pkg, fileName)).unique_identifier } const installLocalPluginPackages = async (pluginUniqueIdentifiers: string[]) => { - const ctx = await createApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/install/pkg', { - data: { plugin_unique_identifiers: pluginUniqueIdentifiers }, - }) - await expectApiResponseOK(response, 'Install uploaded plugin packages') - return (await response.json()) as PluginInstallStartResponse - } finally { - await ctx.dispose() - } + return startUploadedPluginPackageInstall(pluginUniqueIdentifiers) } const shouldFallbackToLocalPackageInstall = (error: string) => diff --git a/e2e/support/tools.ts b/e2e/support/tools.ts deleted file mode 100644 index 11ea82d4dcd..00000000000 --- a/e2e/support/tools.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createApiContext, expectApiResponseOK } from './api' - -export async function deleteBuiltinToolCredential( - provider: string, - credentialId: string, -): Promise { - const ctx = await createApiContext() - try { - const response = await ctx.post( - `/console/api/workspaces/current/tool-provider/builtin/${provider}/delete`, - { - data: { credential_id: credentialId }, - }, - ) - await expectApiResponseOK( - response, - `Delete built-in tool credential ${credentialId} for ${provider}`, - ) - } finally { - await ctx.dispose() - } -} diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index d26268d5f7b..6dc95e1cd7f 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -46,7 +46,7 @@ export type AgentAppDetailWithSite = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array role?: string | null @@ -536,13 +536,31 @@ export type DeletedTool = { type: string } -export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string +export type AppModelConfigResponse = { + agent_mode?: unknown | null + annotation_reply?: unknown | null + chat_prompt_config?: unknown | null + completion_prompt_config?: unknown | null + created_at?: number | null + created_by?: string | null + dataset_configs?: unknown | null + dataset_query_variable?: string | null + external_data_tools?: unknown | null + file_upload?: unknown | null + model?: unknown | null + more_like_this?: unknown | null + opening_statement?: string | null + pre_prompt?: string | null + prompt_type?: string | null + retriever_resource?: unknown | null + sensitive_word_avoidance?: unknown | null + speech_to_text?: unknown | null + suggested_questions?: unknown | null + suggested_questions_after_answer?: unknown | null + text_to_speech?: unknown | null + updated_at?: number | null + updated_by?: string | null + user_input_form?: unknown | null } export type AppDetailSiteResponse = { @@ -1095,8 +1113,6 @@ export type AgentAppPublishedReferenceResponse = { app_name: string } -export type LlmMode = 'chat' | 'completion' - export type AgentKind = 'dify_agent' export type AgentPublishedReferenceResponse = { @@ -1894,7 +1910,7 @@ export type AgentAppDetailWithSiteWritable = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array role?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index cae2f7f9eb3..4e60c853ab8 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -288,6 +288,36 @@ export const zDeletedTool = z.object({ type: z.string(), }) +/** + * AppModelConfigResponse + */ +export const zAppModelConfigResponse = z.object({ + agent_mode: z.unknown().nullish(), + annotation_reply: z.unknown().nullish(), + chat_prompt_config: z.unknown().nullish(), + completion_prompt_config: z.unknown().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + dataset_configs: z.unknown().nullish(), + dataset_query_variable: z.string().nullish(), + external_data_tools: z.unknown().nullish(), + file_upload: z.unknown().nullish(), + model: z.unknown().nullish(), + more_like_this: z.unknown().nullish(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + prompt_type: z.string().nullish(), + retriever_resource: z.unknown().nullish(), + sensitive_word_avoidance: z.unknown().nullish(), + speech_to_text: z.unknown().nullish(), + suggested_questions: z.unknown().nullish(), + suggested_questions_after_answer: z.unknown().nullish(), + text_to_speech: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + user_input_form: z.unknown().nullish(), +}) + /** * AppDetailSiteResponse */ @@ -339,6 +369,47 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) +/** + * AgentAppDetailWithSite + */ +export const zAgentAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + backing_app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + debug_conversation_has_messages: z.boolean().optional().default(false), + debug_conversation_id: z.string().nullish(), + debug_conversation_message_count: z.int().optional().default(0), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + hidden_app_backed: z.boolean().optional().default(false), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + maintainer: z.string().nullish(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zAppModelConfigResponse.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + role: z.string().nullish(), + site: zAppDetailSiteResponse.nullish(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + /** * ComposerBindingPayload */ @@ -1005,64 +1076,6 @@ export const zAgentAppPagination = z.object({ total: z.int(), }) -/** - * LLMMode - * - * Enum class for large language model mode. - */ -export const zLlmMode = z.enum(['chat', 'completion']) - -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - completion_params: z.record(z.string(), z.unknown()).optional(), - mode: zLlmMode, - name: z.string(), - provider: z.string(), -}) - -/** - * AgentAppDetailWithSite - */ -export const zAgentAppDetailWithSite = z.object({ - access_mode: z.string().nullish(), - active_config_is_published: z.boolean().optional().default(false), - api_base_url: z.string().nullish(), - app_id: z.string().nullish(), - backing_app_id: z.string().nullish(), - bound_agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - debug_conversation_has_messages: z.boolean().optional().default(false), - debug_conversation_id: z.string().nullish(), - debug_conversation_message_count: z.int().optional().default(0), - deleted_tools: z.array(zDeletedTool).optional(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - hidden_app_backed: z.boolean().optional().default(false), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - icon_url: z.string().nullable(), - id: z.string(), - maintainer: z.string().nullish(), - max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfig.nullish(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - role: z.string().nullish(), - site: zAppDetailSiteResponse.nullish(), - tags: z.array(zTag).optional(), - tracing: z.unknown().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - /** * AgentKind * @@ -2732,7 +2745,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), - model_config: zModelConfig.nullish(), + model_config: zAppModelConfigResponse.nullish(), name: z.string(), permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index f6513363093..5be8467e1d2 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -40,7 +40,7 @@ export type AppDetailWithSite = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array site?: AppDetailSiteResponse | null @@ -415,7 +415,6 @@ export type AppApiStatusPayload = { export type AppDetail = { access_mode?: string | null - app_model_config?: ModelConfig | null created_at?: number | null created_by?: string | null description?: string | null @@ -425,7 +424,8 @@ export type AppDetail = { icon_background?: string | null id: string maintainer?: string | null - mode_compatible_with_agent: string + mode: string + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array tags?: Array @@ -1020,9 +1020,9 @@ export type SyncDraftWorkflowPayload = { } export type SyncDraftWorkflowResponse = { - hash?: string - result?: string - updated_at?: string + hash: string + result: string + updated_at: number } export type WorkflowDraftVariableList = { @@ -1322,13 +1322,31 @@ export type DeletedTool = { type: string } -export type ModelConfig = { - completion_params?: { - [key: string]: unknown - } - mode: LlmMode - name: string - provider: string +export type AppModelConfigResponse = { + agent_mode?: unknown | null + annotation_reply?: unknown | null + chat_prompt_config?: unknown | null + completion_prompt_config?: unknown | null + created_at?: number | null + created_by?: string | null + dataset_configs?: unknown | null + dataset_query_variable?: string | null + external_data_tools?: unknown | null + file_upload?: unknown | null + model?: unknown | null + more_like_this?: unknown | null + opening_statement?: string | null + pre_prompt?: string | null + prompt_type?: string | null + retriever_resource?: unknown | null + sensitive_word_avoidance?: unknown | null + speech_to_text?: unknown | null + suggested_questions?: unknown | null + suggested_questions_after_answer?: unknown | null + text_to_speech?: unknown | null + updated_at?: number | null + updated_by?: string | null + user_input_form?: unknown | null } export type AppDetailSiteResponse = { @@ -1608,6 +1626,15 @@ export type FeedbackStat = { like: number } +export type ModelConfig = { + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string +} + export type Conversation = { admin_feedback_stats?: FeedbackStat | null annotation?: ConversationAnnotation | null @@ -2196,8 +2223,6 @@ export type ModelConfigPartial = { updated_by?: string | null } -export type LlmMode = 'chat' | 'completion' - export type PluginDependencyType = 'github' | 'marketplace' | 'package' export type Github = { @@ -2259,6 +2284,8 @@ export type StatusCount = { success: number } +export type LlmMode = 'chat' | 'completion' + export type SimpleMessageDetail = { answer: string inputs: { @@ -3075,7 +3102,7 @@ export type AppDetailWithSiteWritable = { maintainer?: string | null max_active_requests?: number | null mode: string - model_config?: ModelConfig | null + model_config?: AppModelConfigResponse | null name: string permission_keys?: Array site?: AppDetailSiteResponseWritable | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 9cc33be2af6..bba24bfa40a 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -663,10 +663,13 @@ export const zSyncDraftWorkflowPayload = z.object({ hash: z.string().nullish(), }) +/** + * SyncDraftWorkflowResponse + */ export const zSyncDraftWorkflowResponse = z.object({ - hash: z.string().optional(), - result: z.string().optional(), - updated_at: z.string().optional(), + hash: z.string(), + result: z.string(), + updated_at: z.int(), }) /** @@ -886,6 +889,36 @@ export const zDeletedTool = z.object({ type: z.string(), }) +/** + * AppModelConfigResponse + */ +export const zAppModelConfigResponse = z.object({ + agent_mode: z.unknown().nullish(), + annotation_reply: z.unknown().nullish(), + chat_prompt_config: z.unknown().nullish(), + completion_prompt_config: z.unknown().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + dataset_configs: z.unknown().nullish(), + dataset_query_variable: z.string().nullish(), + external_data_tools: z.unknown().nullish(), + file_upload: z.unknown().nullish(), + model: z.unknown().nullish(), + more_like_this: z.unknown().nullish(), + opening_statement: z.string().nullish(), + pre_prompt: z.string().nullish(), + prompt_type: z.string().nullish(), + retriever_resource: z.unknown().nullish(), + sensitive_word_avoidance: z.unknown().nullish(), + speech_to_text: z.unknown().nullish(), + suggested_questions: z.unknown().nullish(), + suggested_questions_after_answer: z.unknown().nullish(), + text_to_speech: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + user_input_form: z.unknown().nullish(), +}) + /** * AppDetailSiteResponse */ @@ -937,6 +970,66 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) +/** + * AppDetailWithSite + */ +export const zAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + maintainer: z.string().nullish(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zAppModelConfigResponse.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + site: zAppDetailSiteResponse.nullish(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AppDetail + */ +export const zAppDetail = z.object({ + access_mode: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + id: z.string(), + maintainer: z.string().nullish(), + mode: z.string(), + model_config: zAppModelConfigResponse.nullish(), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + tags: z.array(zTag).optional(), + tracing: z.unknown().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + /** * ImportStatus */ @@ -2251,102 +2344,6 @@ export const zAppPagination = z.object({ total: z.int(), }) -/** - * LLMMode - * - * Enum class for large language model mode. - */ -export const zLlmMode = z.enum(['chat', 'completion']) - -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - completion_params: z.record(z.string(), z.unknown()).optional(), - mode: zLlmMode, - name: z.string(), - provider: z.string(), -}) - -/** - * AppDetailWithSite - */ -export const zAppDetailWithSite = z.object({ - access_mode: z.string().nullish(), - api_base_url: z.string().nullish(), - app_id: z.string().nullish(), - bound_agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - deleted_tools: z.array(zDeletedTool).optional(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - icon_url: z.string().nullable(), - id: z.string(), - maintainer: z.string().nullish(), - max_active_requests: z.int().nullish(), - mode: z.string(), - model_config: zModelConfig.nullish(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - site: zAppDetailSiteResponse.nullish(), - tags: z.array(zTag).optional(), - tracing: z.unknown().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - -/** - * AppDetail - */ -export const zAppDetail = z.object({ - access_mode: z.string().nullish(), - app_model_config: zModelConfig.nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - id: z.string(), - maintainer: z.string().nullish(), - mode_compatible_with_agent: z.string(), - name: z.string(), - permission_keys: z.array(z.string()).optional(), - tags: z.array(zTag).optional(), - tracing: z.unknown().nullish(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.nullish(), -}) - -/** - * ConversationDetail - */ -export const zConversationDetail = z.object({ - admin_feedback_stats: zFeedbackStat.nullish(), - annotated: z.boolean(), - created_at: z.int().nullish(), - from_account_id: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - introduction: z.string().nullish(), - message_count: z.int(), - model_config: zModelConfig.nullish(), - status: z.string(), - updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.nullish(), -}) - /** * PluginDependencyType */ @@ -2537,6 +2534,42 @@ export const zConversationWithSummaryPagination = z.object({ total: z.int(), }) +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) + +/** + * ModelConfig + */ +export const zModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), +}) + +/** + * ConversationDetail + */ +export const zConversationDetail = z.object({ + admin_feedback_stats: zFeedbackStat.nullish(), + annotated: z.boolean(), + created_at: z.int().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + introduction: z.string().nullish(), + message_count: z.int(), + model_config: zModelConfig.nullish(), + status: z.string(), + updated_at: z.int().nullish(), + user_feedback_stats: zFeedbackStat.nullish(), +}) + /** * SimpleMessageDetail */ @@ -4234,7 +4267,7 @@ export const zAppDetailWithSiteWritable = z.object({ maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), - model_config: zModelConfig.nullish(), + model_config: zAppModelConfigResponse.nullish(), name: z.string(), permission_keys: z.array(z.string()).optional(), site: zAppDetailSiteResponseWritable.nullish(), From 855464e7b453242f00e74a7dfcccbb05857673f0 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:34:54 +0800 Subject: [PATCH 26/63] chore: clean up CODEOWNERS (#39398) --- .github/CODEOWNERS | 61 +++++++++++++--------------------------------- 1 file changed, 17 insertions(+), 44 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 50931da0a41..206d45374b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,6 @@ # Lint bulk suppression baselines. /oxlint-suppressions.json -/eslint-suppressions.json # CODEOWNERS file /.github/CODEOWNERS @laipz8200 @crazywoola @@ -33,31 +32,9 @@ # Backend (default owner, more specific rules below will override) /api/ @QuantumGhost -# Backend - MCP -/api/core/mcp/ @Nov1c444 -/api/core/entities/mcp_provider.py @Nov1c444 -/api/services/tools/mcp_tools_manage_service.py @Nov1c444 -/api/controllers/mcp/ @Nov1c444 -/api/controllers/console/app/mcp_server.py @Nov1c444 - # Backend - Tests /api/tests/ @laipz8200 @QuantumGhost -/api/tests/**/*mcp* @Nov1c444 - -# Backend - Workflow - Engine (Core graph execution engine) -/api/core/workflow/graph_engine/ @laipz8200 @QuantumGhost -/api/core/workflow/runtime/ @laipz8200 @QuantumGhost -/api/core/workflow/graph/ @laipz8200 @QuantumGhost -/api/core/workflow/graph_events/ @laipz8200 @QuantumGhost -/api/core/workflow/node_events/ @laipz8200 @QuantumGhost - -# Backend - Workflow - Nodes (Agent, Iteration, Loop, LLM) -/api/core/workflow/nodes/agent/ @Nov1c444 -/api/core/workflow/nodes/iteration/ @Nov1c444 -/api/core/workflow/nodes/loop/ @Nov1c444 -/api/core/workflow/nodes/llm/ @Nov1c444 - # Backend - RAG (Retrieval Augmented Generation) /api/core/rag/ @JohnJyong /api/services/rag_pipeline/ @JohnJyong @@ -111,7 +88,6 @@ /api/core/app/layers/trigger_post_layer.py @CourTeous33 /api/services/trigger/ @CourTeous33 /api/models/trigger.py @CourTeous33 -/api/fields/workflow_trigger_fields.py @CourTeous33 /api/repositories/workflow_trigger_log_repository.py @CourTeous33 /api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33 /api/libs/schedule_utils.py @CourTeous33 @@ -136,11 +112,11 @@ /api/controllers/console/billing/ @hj24 @zyssyz123 # Backend - Enterprise -/api/configs/enterprise/ @GarfieldDai @GareArc -/api/services/enterprise/ @GarfieldDai @GareArc -/api/services/feature_service.py @GarfieldDai @GareArc -/api/controllers/console/feature.py @GarfieldDai @GareArc -/api/controllers/web/feature.py @GarfieldDai @GareArc +/api/configs/enterprise/ @GareArc +/api/services/enterprise/ @GareArc +/api/services/feature_service.py @GareArc +/api/controllers/console/feature.py @GareArc +/api/controllers/web/feature.py @GareArc # Backend - Database Migrations /api/migrations/ @snakevash @laipz8200 @MRZHUH @@ -153,7 +129,6 @@ # Frontend - Platform and Features /web/config/ @lyzno1 -/web/contract/ @lyzno1 /web/env.ts @lyzno1 /web/features/ @lyzno1 /web/hooks/ @lyzno1 @@ -212,7 +187,6 @@ /web/app/components/rag-pipeline/store/ @iamjoel @zxhlyh # Frontend - RAG - Documents List -/web/app/components/datasets/documents/list.tsx @iamjoel @WTW0313 /web/app/components/datasets/documents/create-from-pipeline/ @iamjoel @WTW0313 # Frontend - RAG - Segments List @@ -231,22 +205,22 @@ /web/app/components/plugins/marketplace/ @iamjoel @Yessenia-d # Frontend - Login and Registration -/web/app/signin/ @douxc @iamjoel -/web/app/signup/ @douxc @iamjoel -/web/app/reset-password/ @douxc @iamjoel -/web/app/install/ @douxc @iamjoel -/web/app/init/ @douxc @iamjoel -/web/app/forgot-password/ @douxc @iamjoel -/web/app/account/ @douxc @iamjoel +/web/app/signin/ @iamjoel +/web/app/signup/ @iamjoel +/web/app/reset-password/ @iamjoel +/web/app/install/ @iamjoel +/web/app/init/ @iamjoel +/web/app/forgot-password/ @iamjoel +/web/app/account/ @iamjoel # Frontend - Service Authentication -/web/service/base.ts @douxc @iamjoel +/web/service/base.ts @iamjoel # Frontend - WebApp Authentication and Access Control -/web/app/(shareLayout)/components/ @douxc @iamjoel -/web/app/(shareLayout)/webapp-signin/ @douxc @iamjoel -/web/app/(shareLayout)/webapp-reset-password/ @douxc @iamjoel -/web/app/components/app/app-access-control/ @douxc @iamjoel +/web/app/(shareLayout)/components/ @iamjoel +/web/app/(shareLayout)/webapp-signin/ @iamjoel +/web/app/(shareLayout)/webapp-reset-password/ @iamjoel +/web/app/components/app/app-access-control/ @iamjoel # Frontend - Explore Page /web/app/components/explore/ @CodingOnStar @iamjoel @@ -265,7 +239,6 @@ /web/app/components/base/**/*.spec.tsx @hyoban @CodingOnStar # Frontend - Utils and Hooks -/web/utils/classnames.ts @iamjoel @zxhlyh /web/utils/time.ts @iamjoel @zxhlyh /web/utils/format.ts @iamjoel @zxhlyh /web/utils/clipboard.ts @iamjoel @zxhlyh From a3654d6a7d5b4749e2f418257aefc4a80708ffd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Wed, 22 Jul 2026 13:59:04 +0800 Subject: [PATCH 27/63] fix(oauth): reauth after accepting invitation (#39366) --- api/controllers/console/auth/oauth.py | 56 ++++++---- .../controllers/console/auth/test_oauth.py | 13 ++- .../console/auth/test_oauth_redirect.py | 101 +++++++++++++++++- 3 files changed, 146 insertions(+), 24 deletions(-) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 2160f3e38ec..a49cf47eaf6 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -6,6 +6,7 @@ from flask import current_app, redirect, request from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import Unauthorized +from werkzeug.wrappers import Response from configs import dify_config from constants.languages import languages @@ -127,6 +128,20 @@ def _preferred_interface_language(language: str | None = None) -> str: return languages[0] +def _redirect_with_console_session(account: Account, target_url: str) -> Response: + """Create a console session and attach its cookies to a redirect response.""" + token_pair = AccountService.login( + account=account, + session=db.session(), + ip_address=extract_remote_ip(request), + ) + response = redirect(target_url) + set_access_token_to_cookie(request, response, token_pair.access_token) + set_refresh_token_to_cookie(request, response, token_pair.refresh_token) + set_csrf_token_to_cookie(request, response, token_pair.csrf_token) + return response + + @console_ns.route("/oauth/login/") class OAuthLogin(Resource): @console_ns.doc("oauth_login") @@ -195,16 +210,26 @@ class OAuthCallback(Resource): return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={urllib.parse.quote(str(e))}") if invite_token and RegisterService.is_valid_invite_token(invite_token): - invitation = RegisterService.get_invitation_by_token(token=invite_token) - if invitation: - invitation_email = invitation.get("email", None) - invitation_email_normalized = ( - invitation_email.lower() if isinstance(invitation_email, str) else invitation_email - ) - if invitation_email_normalized != user_info.email.lower(): - return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Invalid invitation token.") + invitation = RegisterService.get_invitation_if_token_valid( + None, + None, + invite_token, + session=db.session(), + ) + if not invitation: + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Invalid invitation token.") + if invitation["data"]["email"].lower() != user_info.email.lower(): + message = "This invitation was sent to another account. Please sign in with the invited account." + query = urllib.parse.urlencode({"message": message, "invite_token": invite_token}) + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?{query}") - return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}") + account = invitation["account"] + if account.status == AccountStatus.BANNED: + return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account is banned.") + + AccountService.link_account_integrate(provider, user_info.id, account, session=db.session()) + target_url = f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}" + return _redirect_with_console_session(account, target_url) try: account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language) @@ -239,21 +264,10 @@ class OAuthCallback(Resource): "?message=Workspace not found, please contact system admin to invite you to join in a workspace." ) - token_pair = AccountService.login( - account=account, - session=db.session(), - ip_address=extract_remote_ip(request), - ) - target_url = _get_redirect_target(redirect_url) query_char = "&" if "?" in target_url else "?" target_url = f"{target_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}" - response = redirect(target_url) - - set_access_token_to_cookie(request, response, token_pair.access_token) - set_refresh_token_to_cookie(request, response, token_pair.refresh_token) - set_csrf_token_to_cookie(request, response, token_pair.csrf_token) - return response + return _redirect_with_console_session(account, target_url) def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Account | None: 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 6964157189d..a32cac0225f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -250,10 +250,12 @@ class TestOAuthCallback: @patch("controllers.console.auth.oauth.dify_config") @patch("controllers.console.auth.oauth.get_oauth_providers") @patch("controllers.console.auth.oauth.RegisterService") + @patch("controllers.console.auth.oauth.AccountService") @patch("controllers.console.auth.oauth.redirect") def test_invitation_comparison_is_case_insensitive( self, mock_redirect, + mock_account_service, mock_register_service, mock_get_providers, mock_config, @@ -267,13 +269,20 @@ class TestOAuthCallback: ) mock_get_providers.return_value = {"github": oauth_setup["provider"]} mock_register_service.is_valid_invite_token.return_value = True - mock_register_service.get_invitation_by_token.return_value = {"email": "user@example.com"} + mock_register_service.get_invitation_if_token_valid.return_value = { + "account": oauth_setup["account"], + "data": {"email": "user@example.com"}, + "tenant": MagicMock(), + } + mock_account_service.login.return_value = oauth_setup["token_pair"] state = encode_oauth_state(invite_token="invite123", timezone="Asia/Shanghai") with app.test_request_context(f"/auth/oauth/github/callback?code=test_code&state={state}"): resource.get("github") - mock_register_service.get_invitation_by_token.assert_called_once_with(token="invite123") + mock_register_service.get_invitation_if_token_valid.assert_called_once_with( + None, None, "invite123", session=ANY + ) mock_redirect.assert_called_once_with("http://localhost:3000/signin/invite-settings?invite_token=invite123") @pytest.mark.parametrize( 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 ac1bace882e..e5a4891fe80 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 @@ -1,5 +1,5 @@ import urllib.parse -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest from flask import Flask @@ -91,3 +91,102 @@ def test_oauth_callback_validates_redirect_url_and_appends_new_user_flag( assert response.headers["Location"] == ( f"{expected_target_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}" ) + + +def test_oauth_callback_with_invitation_establishes_console_session(app: Flask) -> None: + oauth_provider = MagicMock() + oauth_provider.get_access_token.return_value = "google-access-token" + oauth_provider.get_user_info.return_value = OAuthUserInfo( + id="google-user-123", + name="Test User", + email="Invitee@Example.com", + ) + account = MagicMock() + account.status = AccountStatus.ACTIVE + token_pair = MagicMock() + token_pair.access_token = "dify-access-token" + token_pair.refresh_token = "dify-refresh-token" + token_pair.csrf_token = "dify-csrf-token" + state = encode_oauth_state(invite_token="invite-token") + + 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), + 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, + patch("controllers.console.auth.oauth.TenantService.create_owner_tenant_if_not_exist") as create_workspace, + patch("controllers.console.auth.oauth.set_access_token_to_cookie") as set_access_cookie, + patch("controllers.console.auth.oauth.set_refresh_token_to_cookie") as set_refresh_cookie, + patch("controllers.console.auth.oauth.set_csrf_token_to_cookie") as set_csrf_cookie, + app.test_request_context(f"/oauth/authorize/google?code=test-code&state={state}"), + ): + register_service.is_valid_invite_token.return_value = True + register_service.get_invitation_if_token_valid.return_value = { + "account": account, + "data": { + "account_id": "account-id", + "email": "invitee@example.com", + "workspace_id": "workspace-id", + }, + "tenant": MagicMock(), + } + + response = OAuthCallback().get("google") + + assert response.status_code == 302 + assert response.headers["Location"] == (f"{CONSOLE_WEB_URL}/signin/invite-settings?invite_token=invite-token") + link_account.assert_called_once_with("google", "google-user-123", account, session=ANY) + login.assert_called_once_with(account=account, session=ANY, ip_address=ANY) + create_workspace.assert_not_called() + set_access_cookie.assert_called_once_with(ANY, response, "dify-access-token") + set_refresh_cookie.assert_called_once_with(ANY, response, "dify-refresh-token") + set_csrf_cookie.assert_called_once_with(ANY, response, "dify-csrf-token") + + +def test_oauth_callback_with_invitation_rejects_another_account(app: Flask) -> None: + oauth_provider = MagicMock() + oauth_provider.get_access_token.return_value = "google-access-token" + oauth_provider.get_user_info.return_value = OAuthUserInfo( + id="google-user-123", + name="Test User", + email="another@example.com", + ) + account = MagicMock() + account.status = AccountStatus.ACTIVE + state = encode_oauth_state(invite_token="invite-token") + + 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), + 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, + patch("controllers.console.auth.oauth.set_access_token_to_cookie") as set_access_cookie, + patch("controllers.console.auth.oauth.set_refresh_token_to_cookie") as set_refresh_cookie, + patch("controllers.console.auth.oauth.set_csrf_token_to_cookie") as set_csrf_cookie, + app.test_request_context(f"/oauth/authorize/google?code=test-code&state={state}"), + ): + register_service.is_valid_invite_token.return_value = True + register_service.get_invitation_if_token_valid.return_value = { + "account": account, + "data": { + "account_id": "account-id", + "email": "invitee@example.com", + "workspace_id": "workspace-id", + }, + "tenant": MagicMock(), + } + + response = OAuthCallback().get("google") + + query = urllib.parse.parse_qs(urllib.parse.urlparse(response.headers["Location"]).query) + assert response.status_code == 302 + assert query["message"] == ["This invitation was sent to another account. Please sign in with the invited account."] + assert query["invite_token"] == ["invite-token"] + link_account.assert_not_called() + login.assert_not_called() + register_service.revoke_token.assert_not_called() + set_access_cookie.assert_not_called() + set_refresh_cookie.assert_not_called() + set_csrf_cookie.assert_not_called() From 4b355b703998bfc1bada174d7c3e84f90912750f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:45:18 +0800 Subject: [PATCH 28/63] fix(ci): avoid duplicate post-merge e2e runs (#39401) --- .github/workflows/main-ci.yml | 2 ++ .github/workflows/post-merge.yml | 16 ++++++++++++++++ .github/workflows/web-e2e.yml | 11 +++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 18542a1f18d..aec8514a69d 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -335,6 +335,8 @@ jobs: - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true' uses: ./.github/workflows/web-e2e.yml + with: + run-external-runtime: false secrets: inherit web-e2e-skip: diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index 60e15c25e13..c7ee9850b08 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -26,19 +26,30 @@ jobs: external_e2e: - 'e2e/features/agent-v2/**' - 'e2e/features/step-definitions/agent-v2/**' + - 'e2e/features/step-definitions/common/**' - 'e2e/features/support/**' + - 'e2e/fixtures/auth.ts' - 'e2e/fixtures/test-materials/**' - 'e2e/scripts/**' - 'e2e/support/**' - 'e2e/cucumber.config.ts' - 'e2e/package.json' - 'e2e/test-env.ts' + - 'e2e/tsconfig.json' - 'e2e/tsx-register.js' + - 'package.json' + - 'pnpm-lock.yaml' + - '.nvmrc' - '.github/workflows/post-merge.yml' - '.github/workflows/web-e2e.yml' - '.github/actions/setup-web/**' + - 'docker/docker-compose.middleware.yaml' + - 'docker/envs/middleware.env.example' - 'dify-agent/**' - 'dify-agent-runtime/**' + - 'api/pyproject.toml' + - 'api/uv.lock' + - 'api/tests/integration_tests/.env.example' - 'api/clients/agent_backend/**' - 'api/core/app/apps/agent_app/**' - 'api/core/workflow/nodes/agent_v2/**' @@ -48,8 +59,13 @@ jobs: - 'api/services/plugin/**' - 'api/core/tools/**' - 'api/services/tools/**' + - 'packages/contracts/package.json' - 'packages/contracts/generated/api/console/agent/**' + - 'packages/contracts/generated/api/console/apps/**' + - 'packages/contracts/generated/api/console/datasets/**' - 'packages/contracts/generated/api/console/orpc.gen.ts' + - 'packages/contracts/generated/api/console/workspaces/**' + - 'packages/contracts/generated/api/service/**' - 'web/features/agent-v2/**' - 'web/app/(commonLayout)/agents/**' - 'web/app/(commonLayout)/@detailSidebar/agents/**' diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index d7cd2657d58..df7ed8d7c92 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -4,9 +4,9 @@ on: workflow_call: inputs: run-external-runtime: - required: false + description: Run only the prepared and external runtime suite instead of the core suites. + required: true type: boolean - default: false permissions: contents: read @@ -46,6 +46,7 @@ jobs: run: uv sync --project api --dev - name: Run E2E support unit tests + if: ${{ !inputs.run-external-runtime }} working-directory: ./e2e run: vp run test:unit @@ -54,6 +55,7 @@ jobs: run: vp run e2e:install - name: Run isolated source-api and built-web Cucumber E2E tests + if: ${{ !inputs.run-external-runtime }} working-directory: ./e2e env: E2E_ADMIN_EMAIL: e2e-admin@example.com @@ -64,7 +66,7 @@ jobs: run: vp run e2e:full - name: Preserve Chromium E2E report and logs - if: ${{ !cancelled() }} + if: ${{ !cancelled() && !inputs.run-external-runtime }} run: | if [[ -d e2e/cucumber-report ]]; then mv e2e/cucumber-report e2e/cucumber-report-non-external @@ -74,6 +76,7 @@ jobs: fi - name: Run WebKit keyboard and browser smoke tests + if: ${{ !inputs.run-external-runtime }} working-directory: ./e2e env: E2E_ADMIN_EMAIL: e2e-admin@example.com @@ -99,7 +102,7 @@ jobs: vp run e2e -- --tags '@browser-smoke' - name: Preserve WebKit E2E report and logs - if: ${{ !cancelled() }} + if: ${{ !cancelled() && !inputs.run-external-runtime }} run: | if [[ -d e2e/cucumber-report ]]; then mv e2e/cucumber-report e2e/cucumber-report-webkit From c76ff4c38c1e055fce20cefe7c99247b209ab928 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 16:21:22 +0900 Subject: [PATCH 29/63] test: use sqlite3 session in test_base_app_runner (#38740) Co-authored-by: Byron.wang --- .../core/app/apps/test_base_app_runner.py | 207 +++++++++++------- 1 file changed, 125 insertions(+), 82 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py index deb9ab4d2af..dcd9c2b76af 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py @@ -2,10 +2,10 @@ from __future__ import annotations import logging from contextlib import nullcontext -from types import SimpleNamespace -from unittest.mock import MagicMock import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.app.app_config.entities import ( AdvancedChatMessageEntity, @@ -15,7 +15,13 @@ from core.app.app_config.entities import ( ) from core.app.apps.base_app_runner import AppRunner from core.app.apps.exc import GenerateTaskStoppedError -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager +from core.app.entities.app_invoke_entities import ( + AppGenerateEntity, + EasyUIBasedAppGenerateEntity, + InvokeFrom, + ModelConfigWithCredentialsEntity, +) from core.app.entities.queue_entities import ( QueueAgentMessageEvent, QueueLLMChunkEvent, @@ -29,9 +35,9 @@ from graphon.model_runtime.entities.message_entities import ( PromptMessageRole, TextPromptMessageContent, ) -from graphon.model_runtime.entities.model_entities import ModelPropertyKey +from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey from graphon.model_runtime.errors.invoke import InvokeBadRequestError -from models.model import AppMode +from models.model import App, AppMode, Message, MessageFile class _DummyParameterRule: @@ -40,13 +46,29 @@ class _DummyParameterRule: self.use_template = use_template -class _QueueRecorder: - def __init__(self) -> None: - self.events: list[object] = [] +class _TokenCountingModel: + token_count: int - def publish(self, event, pub_from): - _ = pub_from - self.events.append(event) + def __init__(self, token_count: int) -> None: + self.token_count = token_count + + def get_llm_num_tokens(self, messages: list[AssistantPromptMessage]) -> int: + return self.token_count + + +def _queue_manager() -> MessageBasedAppQueueManager: + return MessageBasedAppQueueManager( + task_id="task-id", + user_id="user-id", + invoke_from=InvokeFrom.SERVICE_API, + conversation_id="conversation-id", + app_mode=AppMode.CHAT.value, + message_id="message-id", + ) + + +def _published_events(queue_manager: MessageBasedAppQueueManager) -> list[object]: + return [message.event for message in queue_manager.listen()] class _ClosableStream: @@ -70,11 +92,11 @@ class TestAppRunner: def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_schema = SimpleNamespace( + model_schema = AIModelEntity.model_construct( model_properties={ModelPropertyKey.CONTEXT_SIZE: 100}, parameter_rules=[_DummyParameterRule("max_tokens")], ) - model_config = SimpleNamespace( + model_config = ModelConfigWithCredentialsEntity.model_construct( provider_model_bundle=object(), model="mock", model_schema=model_schema, @@ -83,7 +105,7 @@ class TestAppRunner: monkeypatch.setattr( "core.app.apps.base_app_runner.ModelInstance", - lambda provider_model_bundle, model: SimpleNamespace(get_llm_num_tokens=lambda messages: 80), + lambda provider_model_bundle, model: _TokenCountingModel(80), ) runner.recalc_llm_max_tokens(model_config, prompt_messages=[AssistantPromptMessage(content="hi")]) @@ -93,11 +115,11 @@ class TestAppRunner: def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_schema = SimpleNamespace( + model_schema = AIModelEntity.model_construct( model_properties={}, parameter_rules=[_DummyParameterRule("max_tokens")], ) - model_config = SimpleNamespace( + model_config = ModelConfigWithCredentialsEntity.model_construct( provider_model_bundle=object(), model="mock", model_schema=model_schema, @@ -106,17 +128,16 @@ class TestAppRunner: monkeypatch.setattr( "core.app.apps.base_app_runner.ModelInstance", - lambda provider_model_bundle, model: SimpleNamespace(get_llm_num_tokens=lambda messages: 10), + lambda provider_model_bundle, model: _TokenCountingModel(10), ) assert runner.recalc_llm_max_tokens(model_config, prompt_messages=[]) == -1 - def test_direct_output_streaming_publishes_chunks_and_end(self, monkeypatch: pytest.MonkeyPatch): + def test_direct_output_streaming_publishes_chunks_and_end(self): runner = AppRunner() - queue = _QueueRecorder() - app_generate_entity = SimpleNamespace(model_conf=SimpleNamespace(model="mock"), stream=True) - - monkeypatch.setattr("core.app.apps.base_app_runner.time.sleep", lambda _: None) + queue = _queue_manager() + model_config = ModelConfigWithCredentialsEntity.model_construct(model="mock") + app_generate_entity = EasyUIBasedAppGenerateEntity.model_construct(model_conf=model_config, stream=True) runner.direct_output( queue_manager=queue, @@ -126,12 +147,13 @@ class TestAppRunner: stream=True, ) - assert any(isinstance(event, QueueLLMChunkEvent) for event in queue.events) - assert isinstance(queue.events[-1], QueueMessageEndEvent) + events = _published_events(queue) + assert any(isinstance(event, QueueLLMChunkEvent) for event in events) + assert isinstance(events[-1], QueueMessageEndEvent) def test_handle_invoke_result_direct_publishes_end_event(self): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() llm_result = LLMResult( model="mock", prompt_messages=[], @@ -145,11 +167,11 @@ class TestAppRunner: stream=False, ) - assert isinstance(queue.events[-1], QueueMessageEndEvent) + assert isinstance(_published_events(queue)[-1], QueueMessageEndEvent) def test_handle_invoke_result_invalid_type_raises(self): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() with pytest.raises(NotImplementedError): runner._handle_invoke_result( @@ -160,7 +182,7 @@ class TestAppRunner: def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_config = SimpleNamespace(mode="chat", stop=["STOP"]) + model_config = ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=["STOP"]) prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.SIMPLE, simple_prompt_template="hello", @@ -172,7 +194,7 @@ class TestAppRunner: ) prompt_messages, stop = runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), + app_record=App(mode=AppMode.CHAT.value), model_config=model_config, prompt_template_entity=prompt_template_entity, inputs={}, @@ -185,7 +207,7 @@ class TestAppRunner: def test_organize_prompt_messages_advanced_completion_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_config = SimpleNamespace(mode="completion", stop=[""]) + model_config = ModelConfigWithCredentialsEntity.model_construct(mode="completion", stop=[""]) captured: dict[str, object] = {} prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.ADVANCED, @@ -202,7 +224,7 @@ class TestAppRunner: monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt) prompt_messages, stop = runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), + app_record=App(mode=AppMode.CHAT.value), model_config=model_config, prompt_template_entity=prompt_template_entity, inputs={}, @@ -218,7 +240,7 @@ class TestAppRunner: def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - model_config = SimpleNamespace(mode="chat", stop=[""]) + model_config = ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=[""]) captured: dict[str, object] = {} prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.ADVANCED, @@ -237,7 +259,7 @@ class TestAppRunner: monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt) prompt_messages, stop = runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), + app_record=App(mode=AppMode.CHAT.value), model_config=model_config, prompt_template_entity=prompt_template_entity, inputs={}, @@ -254,8 +276,8 @@ class TestAppRunner: with pytest.raises(InvokeBadRequestError, match="Advanced completion prompt template is required"): runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), - model_config=SimpleNamespace(mode="completion", stop=[]), + app_record=App(mode=AppMode.CHAT.value), + model_config=ModelConfigWithCredentialsEntity.model_construct(mode="completion", stop=[]), prompt_template_entity=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.ADVANCED), inputs={}, files=[], @@ -263,18 +285,16 @@ class TestAppRunner: with pytest.raises(InvokeBadRequestError, match="Advanced chat prompt template is required"): runner.organize_prompt_messages( - app_record=SimpleNamespace(mode=AppMode.CHAT.value), - model_config=SimpleNamespace(mode="chat", stop=[]), + app_record=App(mode=AppMode.CHAT.value), + model_config=ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=[]), prompt_template_entity=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.ADVANCED), inputs={}, files=[], ) - def test_handle_invoke_result_stream_routes_chunks_and_builds_message( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ): + def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, caplog: pytest.LogCaptureFixture): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() image_content = ImagePromptMessageContent( url="https://example.com/image.png", format="png", mime_type="image/png" @@ -286,11 +306,9 @@ class TestAppRunner: prompt_messages=[AssistantPromptMessage(content="prompt")], delta=LLMResultChunkDelta( index=0, - message=AssistantPromptMessage.model_construct( + message=AssistantPromptMessage( content=[ - "a", - TextPromptMessageContent(data="b"), - SimpleNamespace(data="c"), + TextPromptMessageContent(data="abc"), image_content, ] ), @@ -305,21 +323,25 @@ class TestAppRunner: agent=False, ) - assert isinstance(queue.events[0], QueueLLMChunkEvent) - assert isinstance(queue.events[-1], QueueMessageEndEvent) - assert queue.events[-1].llm_result.message.content == "abc" + events = _published_events(queue) + assert isinstance(events[0], QueueLLMChunkEvent) + assert isinstance(events[-1], QueueMessageEndEvent) + assert events[-1].llm_result.message.content == "abc" assert "Received multimodal output but missing required parameters" in caplog.messages def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): runner = AppRunner() - queue = _QueueRecorder() + queue = _queue_manager() + + def raise_multimodal_error(**kwargs): + raise RuntimeError("failed to save image") monkeypatch.setattr( runner, "_handle_multimodal_image_content", - MagicMock(side_effect=RuntimeError("failed to save image")), + raise_multimodal_error, ) usage = LLMUsage.empty_usage() @@ -353,22 +375,37 @@ class TestAppRunner: tenant_id="tenant-id", ) - assert isinstance(queue.events[0], QueueAgentMessageEvent) - assert isinstance(queue.events[-1], QueueMessageEndEvent) - assert queue.events[-1].llm_result.usage == usage + events = _published_events(queue) + assert isinstance(events[0], QueueAgentMessageEvent) + assert isinstance(events[-1], QueueMessageEndEvent) + assert events[-1].llm_result.usage == usage assert "Failed to handle multimodal image output" in caplog.messages - def test_handle_invoke_result_stream_commits_message_file_before_publish(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_handle_invoke_result_stream_commits_message_file_before_publish( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + ): runner = AppRunner() - runner._handle_multimodal_image_content = MagicMock(return_value="message-file-1") - session = MagicMock() + monkeypatch.setattr( + runner, + "_handle_multimodal_image_content", + lambda **kwargs: "message-file-1", + ) events: list[str] = [] - session.commit.side_effect = lambda: events.append("commit") + original_commit = sqlite_session.commit + + def commit(): + events.append("commit") + original_commit() + + monkeypatch.setattr(sqlite_session, "commit", commit) monkeypatch.setattr( "core.app.apps.base_app_runner.session_factory.create_session", - lambda: nullcontext(session), + lambda: nullcontext(sqlite_session), ) - queue = _QueueRecorder() + queue = _queue_manager() original_publish = queue.publish def publish(event, pub_from): @@ -407,7 +444,7 @@ class TestAppRunner: assert events == ["commit", "publish"] - def test_handle_invoke_result_stream_closes_generator_when_stopped(self): + def test_handle_invoke_result_stream_closes_generator_when_stopped(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() chunk = LLMResultChunk( model="stream-model", @@ -416,9 +453,8 @@ class TestAppRunner: ) stream = _ClosableStream([chunk]) - queue_manager = SimpleNamespace( - publish=MagicMock(side_effect=GenerateTaskStoppedError("stopped")), - ) + queue_manager = _queue_manager() + monkeypatch.setattr(queue_manager, "_is_stopped", lambda: True) with pytest.raises(GenerateTaskStoppedError): runner._handle_invoke_result_stream( @@ -429,7 +465,11 @@ class TestAppRunner: assert stream.closed is True - def test_handle_multimodal_image_content_fallback_return_branch(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [(MessageFile,)], indirect=True) + def test_handle_multimodal_image_content_fallback_return_branch( + self, + sqlite_session: Session, + ): runner = AppRunner() class _ToggleBool: @@ -442,19 +482,17 @@ class TestAppRunner: self._index += 1 return value - content = SimpleNamespace( + # The fallback is reachable only when the fields change truthiness between the guard and branch checks. + content = ImagePromptMessageContent.model_construct( url=_ToggleBool([False, False]), base64_data=_ToggleBool([True, False]), mime_type="image/png", ) - db_session = SimpleNamespace(add=MagicMock(), flush=MagicMock(), refresh=MagicMock()) - monkeypatch.setattr("core.app.apps.base_app_runner.ToolFileManager", lambda: MagicMock()) - - queue_manager = SimpleNamespace(invoke_from=InvokeFrom.SERVICE_API, publish=MagicMock()) + queue_manager = _queue_manager() runner._handle_multimodal_image_content( - session=db_session, + session=sqlite_session, content=content, message_id="message-id", user_id="user-id", @@ -462,20 +500,20 @@ class TestAppRunner: queue_manager=queue_manager, ) - db_session.add.assert_not_called() - queue_manager.publish.assert_not_called() + message_file_count = sqlite_session.scalar(select(func.count()).select_from(MessageFile)) + assert message_file_count == 0 def test_check_hosting_moderation_direct_output_called(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() - queue = _QueueRecorder() - app_generate_entity = SimpleNamespace(stream=False) + queue = _queue_manager() + app_generate_entity = EasyUIBasedAppGenerateEntity.model_construct(stream=False) + direct_output_calls: list[dict[str, object]] = [] monkeypatch.setattr( "core.app.apps.base_app_runner.HostingModerationFeature.check", lambda self, application_generate_entity, prompt_messages: True, ) - direct_output = MagicMock() - monkeypatch.setattr(runner, "direct_output", direct_output) + monkeypatch.setattr(runner, "direct_output", lambda **kwargs: direct_output_calls.append(kwargs)) result = runner.check_hosting_moderation( application_generate_entity=app_generate_entity, @@ -484,7 +522,7 @@ class TestAppRunner: ) assert result is True - assert direct_output.called + assert len(direct_output_calls) == 1 def test_fill_in_inputs_from_external_data_tools(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() @@ -509,7 +547,7 @@ class TestAppRunner: "core.app.apps.base_app_runner.InputModeration.check", lambda self, app_id, tenant_id, app_config, inputs, query, message_id, trace_manager: (True, {}, ""), ) - app_generate_entity = SimpleNamespace(app_config=SimpleNamespace(), trace_manager=None) + app_generate_entity = AppGenerateEntity.model_construct(app_config=None, trace_manager=None) result = runner.moderation_for_inputs( app_id="app", @@ -522,7 +560,12 @@ class TestAppRunner: assert result == (True, {}, "") - def test_query_app_annotations_to_reply(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_query_app_annotations_to_reply( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + ): runner = AppRunner() monkeypatch.setattr( "core.app.apps.base_app_runner.AnnotationReplyFeature.query", @@ -530,12 +573,12 @@ class TestAppRunner: ) response = runner.query_app_annotations_to_reply( - app_record=SimpleNamespace(), - message=SimpleNamespace(), + app_record=App(), + message=Message(), query="hello", user_id="user", invoke_from=InvokeFrom.WEB_APP, - session=MagicMock(), + session=sqlite_session, ) assert response == "reply" From c5aadfe557252f8f0fd8d892fbc32eb0737b20a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Wed, 22 Jul 2026 15:30:16 +0800 Subject: [PATCH 30/63] fix(api,billing): invalidate vector space cache after cleanup (#39404) --- api/extensions/ext_celery.py | 1 + api/services/billing_service.py | 8 ++- api/services/dataset_service.py | 12 +++- api/tasks/batch_clean_document_task.py | 13 +++- api/tasks/clean_dataset_task.py | 5 ++ api/tasks/clean_document_task.py | 14 ++++- .../refresh_billing_vector_space_task.py | 59 +++++++++++++++++++ .../test_clean_when_document_deleted.py | 15 +++++ .../services/test_billing_service.py | 21 +++++++ .../tasks/test_batch_clean_document_task.py | 58 ++++++++++++++++++ .../tasks/test_clean_dataset_task.py | 42 ++++++++++--- .../tasks/test_clean_document_task.py | 42 +++++++------ .../test_refresh_billing_vector_space_task.py | 45 ++++++++++++++ 13 files changed, 304 insertions(+), 31 deletions(-) create mode 100644 api/tasks/refresh_billing_vector_space_task.py create mode 100644 api/tests/unit_tests/events/event_handlers/test_clean_when_document_deleted.py create mode 100644 api/tests/unit_tests/tasks/test_batch_clean_document_task.py create mode 100644 api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 2748c0736b0..2cf3505e918 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -157,6 +157,7 @@ def init_app(app: DifyApp) -> Celery: "tasks.regenerate_summary_index_task", # summary index regeneration "tasks.initialize_created_app_rbac_access_task", # app access initialization "tasks.install_default_plugins_task", # tenant default plugin installation + "tasks.refresh_billing_vector_space_task", # billing vector-space cache refresh "tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume "tasks.workflow_run_archive_download_tasks", # workflow-run archive download preparation ] diff --git a/api/services/billing_service.py b/api/services/billing_service.py index ec00d5852fc..aef5ae2f02b 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -217,12 +217,18 @@ class BillingService: return _billing_info_adapter.validate_python(billing_info) @classmethod - def get_vector_space(cls, tenant_id: str) -> _VectorSpaceQuota: + def get_vector_space(cls, tenant_id: str, bypass_cache: bool = False) -> _VectorSpaceQuota: params = {"tenant_id": tenant_id} + if bypass_cache: + params["bypass_cache"] = "true" return _vector_space_quota_adapter.validate_python( cls._send_request("GET", "/subscription/vector-space", params=params) ) + @classmethod + def invalidate_vector_space_cache(cls, tenant_id: str) -> None: + cls.get_vector_space(tenant_id, bypass_cache=True) + @classmethod def get_tenant_feature_plan_usage_info(cls, tenant_id: str): """Deprecated: Use get_quota_info instead.""" diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index a8b83b78047..27650e68101 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1974,7 +1974,10 @@ class DocumentService: if data_source_info and "upload_file_id" in data_source_info: file_id = data_source_info["upload_file_id"] document_was_deleted.send( - document.id, dataset_id=document.dataset_id, doc_form=document.doc_form, file_id=file_id + document.id, + dataset_id=document.dataset_id, + doc_form=document.doc_form, + file_id=file_id, ) session.delete(document) @@ -2013,7 +2016,12 @@ class DocumentService: # Dispatch cleanup task after commit to avoid lock contention # Task cleans up segments, files, and vector indexes if deleted_document_ids and doc_form is not None: - batch_clean_document_task.delay(deleted_document_ids, dataset_ref.dataset_id, doc_form, file_ids) + batch_clean_document_task.delay( + deleted_document_ids, + dataset_ref.dataset_id, + doc_form, + file_ids, + ) @staticmethod def rename_document(dataset_id: str, document_id: str, name: str, session: Session) -> Document: diff --git a/api/tasks/batch_clean_document_task.py b/api/tasks/batch_clean_document_task.py index d243663a428..11cf4b9835c 100644 --- a/api/tasks/batch_clean_document_task.py +++ b/api/tasks/batch_clean_document_task.py @@ -13,6 +13,7 @@ from core.tools.utils.web_reader_tool import get_image_upload_file_ids from extensions.ext_storage import storage from models.dataset import Dataset, DatasetMetadataBinding, DocumentSegment from models.model import UploadFile +from tasks.refresh_billing_vector_space_task import schedule_billing_vector_space_refresh logger = logging.getLogger(__name__) @@ -21,7 +22,12 @@ BATCH_SIZE = 1000 @shared_task(queue="dataset") -def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form: str | None, file_ids: list[str]): +def batch_clean_document_task( + document_ids: list[str], + dataset_id: str, + doc_form: str | None, + file_ids: list[str], +) -> None: """ Clean document when document deleted. :param document_ids: document ids @@ -40,6 +46,7 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form index_node_ids: list[str] = [] segment_ids: list[str] = [] total_image_upload_file_ids: list[str] = [] + dataset_tenant_id: str | None = None try: # ============ Step 1: Query segment and file data (short read-only transaction) ============ @@ -88,6 +95,7 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form delete_summaries=True, session=session, ) + dataset_tenant_id = dataset.tenant_id except Exception: logger.exception( "Failed to clean vector index for dataset_id: %s, document_ids: %s, index_node_ids count: %d", @@ -203,6 +211,9 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form dataset_id, ) + if dataset_tenant_id is not None: + schedule_billing_vector_space_refresh(dataset_tenant_id) + end_at = time.perf_counter() logger.info( click.style( diff --git a/api/tasks/clean_dataset_task.py b/api/tasks/clean_dataset_task.py index 195114499a0..5bf8784e3c2 100644 --- a/api/tasks/clean_dataset_task.py +++ b/api/tasks/clean_dataset_task.py @@ -24,6 +24,7 @@ from models.dataset import ( ) from models.model import UploadFile from models.workflow import Workflow +from tasks.refresh_billing_vector_space_task import schedule_billing_vector_space_refresh logger = logging.getLogger(__name__) @@ -52,6 +53,7 @@ def clean_dataset_task( """ logger.info(click.style(f"Start clean dataset when dataset deleted: {dataset_id}", fg="green")) start_at = time.perf_counter() + vector_cleanup_succeeded = False with session_factory.create_session() as session: try: @@ -93,6 +95,7 @@ def clean_dataset_task( try: index_processor = IndexProcessorFactory(doc_form).init_index_processor() index_processor.clean(dataset, None, with_keywords=True, delete_child_chunks=True, session=session) + vector_cleanup_succeeded = True logger.info(click.style(f"Successfully cleaned vector database for dataset: {dataset_id}", fg="green")) except Exception: logger.exception(click.style(f"Failed to clean vector database for dataset {dataset_id}", fg="red")) @@ -186,6 +189,8 @@ def clean_dataset_task( session.execute(file_delete_stmt) session.commit() + if vector_cleanup_succeeded: + schedule_billing_vector_space_refresh(dataset.tenant_id) end_at = time.perf_counter() logger.info( click.style( diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index 25887c9b704..e09743a0018 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -11,12 +11,18 @@ from core.tools.utils.web_reader_tool import get_image_upload_file_ids from extensions.ext_storage import storage from models.dataset import Dataset, DatasetMetadataBinding, DocumentSegment, SegmentAttachmentBinding from models.model import UploadFile +from tasks.refresh_billing_vector_space_task import schedule_billing_vector_space_refresh logger = logging.getLogger(__name__) @shared_task(queue="dataset") -def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_id: str | None): +def clean_document_task( + document_id: str, + dataset_id: str, + doc_form: str, + file_id: str | None, +) -> None: """ Clean document when document deleted. :param document_id: document id @@ -29,6 +35,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i logger.info(click.style(f"Start clean document when document deleted: {document_id}", fg="green")) start_at = time.perf_counter() total_attachment_files = [] + vector_cleanup_succeeded = False with session_factory.create_session() as session: try: @@ -37,6 +44,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i if not dataset: raise Exception("Document has no dataset") + dataset_tenant_id = dataset.tenant_id segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() # Use JOIN to fetch attachments with bindings in a single query attachments_with_bindings = session.execute( @@ -82,6 +90,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i delete_summaries=True, session=session, ) + vector_cleanup_succeeded = True except Exception: logger.exception( "Failed to clean vector / keyword index in clean_document_task, " @@ -154,6 +163,9 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i ) ) + if vector_cleanup_succeeded: + schedule_billing_vector_space_refresh(dataset_tenant_id) + end_at = time.perf_counter() logger.info( click.style( diff --git a/api/tasks/refresh_billing_vector_space_task.py b/api/tasks/refresh_billing_vector_space_task.py new file mode 100644 index 00000000000..ff3da012e3e --- /dev/null +++ b/api/tasks/refresh_billing_vector_space_task.py @@ -0,0 +1,59 @@ +import logging + +from celery import shared_task +from opentelemetry import metrics + +from configs import dify_config +from services.billing_service import BillingService + +logger = logging.getLogger(__name__) + +_MAX_RETRIES = 3 +_RETRY_DELAY_SECONDS = 30 +_refresh_counter = metrics.get_meter(__name__).create_counter( + "billing.vector_space_cache_refresh.count", + description="Number of billing vector-space cache refresh outcomes", +) + + +@shared_task(queue="dataset", bind=True, max_retries=_MAX_RETRIES, default_retry_delay=_RETRY_DELAY_SECONDS) +def refresh_billing_vector_space_task(self, tenant_id: str) -> None: + """Refresh billing vector-space usage after vector cleanup has completed.""" + if not dify_config.BILLING_ENABLED: + return + + try: + BillingService.invalidate_vector_space_cache(tenant_id) + except Exception as exc: + if self.request.retries >= _MAX_RETRIES: + _refresh_counter.add(1, {"outcome": "exhausted"}) + logger.exception( + "Billing vector-space cache refresh retry budget exhausted, tenant_id=%s", + tenant_id, + ) + raise + + _refresh_counter.add(1, {"outcome": "retry"}) + logger.warning( + "Billing vector-space cache refresh failed, scheduling retry %d/%d, tenant_id=%s", + self.request.retries + 1, + _MAX_RETRIES, + tenant_id, + exc_info=True, + ) + raise self.retry(exc=exc, countdown=_RETRY_DELAY_SECONDS * (2**self.request.retries)) + + _refresh_counter.add(1, {"outcome": "success"}) + logger.info("Billing vector-space cache refreshed, tenant_id=%s", tenant_id) + + +def schedule_billing_vector_space_refresh(tenant_id: str) -> None: + """Dispatch a best-effort billing refresh without changing cleanup status.""" + if not dify_config.BILLING_ENABLED: + return + + try: + refresh_billing_vector_space_task.delay(tenant_id) + except Exception: + _refresh_counter.add(1, {"outcome": "dispatch_failure"}) + logger.exception("Failed to dispatch billing vector-space cache refresh, tenant_id=%s", tenant_id) diff --git a/api/tests/unit_tests/events/event_handlers/test_clean_when_document_deleted.py b/api/tests/unit_tests/events/event_handlers/test_clean_when_document_deleted.py new file mode 100644 index 00000000000..098a7b67880 --- /dev/null +++ b/api/tests/unit_tests/events/event_handlers/test_clean_when_document_deleted.py @@ -0,0 +1,15 @@ +from unittest.mock import patch + +from events.event_handlers.clean_when_document_deleted import handle + + +def test_handler_dispatches_cleanup_task(): + with patch("events.event_handlers.clean_when_document_deleted.clean_document_task.delay") as delay: + handle( + "document-1", + dataset_id="dataset-1", + doc_form="paragraph", + file_id="file-1", + ) + + delay.assert_called_once_with("document-1", "dataset-1", "paragraph", "file-1") diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index f771eabcf8c..a8d405ae8a3 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -462,6 +462,27 @@ class TestBillingServiceSubscriptionInfo: params={"tenant_id": tenant_id}, ) + def test_get_vector_space_bypasses_cache(self, mock_send_request): + tenant_id = "tenant-123" + mock_send_request.return_value = {"size": 4096, "limit": 20480} + + result = BillingService.get_vector_space(tenant_id, bypass_cache=True) + + assert result == {"size": 4096, "limit": 20480} + mock_send_request.assert_called_once_with( + "GET", + "/subscription/vector-space", + params={"tenant_id": tenant_id, "bypass_cache": "true"}, + ) + + def test_invalidate_vector_space_cache_bypasses_cache(self): + tenant_id = "tenant-123" + + with patch.object(BillingService, "get_vector_space") as get_vector_space: + BillingService.invalidate_vector_space_cache(tenant_id) + + get_vector_space.assert_called_once_with(tenant_id, bypass_cache=True) + def test_quota_get_balance_uses_quota_request(self): tenant_id = "tenant-123" with patch.object(BillingService, "_send_quota_request") as mock_send_quota_request: diff --git a/api/tests/unit_tests/tasks/test_batch_clean_document_task.py b/api/tests/unit_tests/tasks/test_batch_clean_document_task.py new file mode 100644 index 00000000000..6386c72188e --- /dev/null +++ b/api/tests/unit_tests/tasks/test_batch_clean_document_task.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock, patch + +from tasks.batch_clean_document_task import batch_clean_document_task + + +def _setup_cleanup_dependencies(): + session = MagicMock() + segment = MagicMock(id="segment-1", index_node_id="node-1", content="content") + dataset = MagicMock(id="dataset-1", tenant_id="tenant-1") + session.scalars.return_value.all.return_value = [segment] + session.scalar.return_value = dataset + + context_manager = MagicMock() + context_manager.__enter__.return_value = session + context_manager.__exit__.return_value = None + return session, context_manager + + +def test_successful_vector_cleanup_schedules_billing_refresh(): + _, context_manager = _setup_cleanup_dependencies() + + with ( + patch("tasks.batch_clean_document_task.session_factory.create_session", return_value=context_manager), + patch("tasks.batch_clean_document_task.get_image_upload_file_ids", return_value=[]), + patch("tasks.batch_clean_document_task.IndexProcessorFactory") as processor_factory, + patch("tasks.batch_clean_document_task.schedule_billing_vector_space_refresh") as schedule_refresh, + ): + batch_clean_document_task( + document_ids=["document-1"], + dataset_id="dataset-1", + doc_form="paragraph", + file_ids=[], + ) + + processor_factory.return_value.init_index_processor.return_value.clean.assert_called_once() + schedule_refresh.assert_called_once_with("tenant-1") + + +def test_failed_vector_cleanup_does_not_schedule_billing_refresh(): + _, context_manager = _setup_cleanup_dependencies() + + with ( + patch("tasks.batch_clean_document_task.session_factory.create_session", return_value=context_manager), + patch("tasks.batch_clean_document_task.get_image_upload_file_ids", return_value=[]), + patch("tasks.batch_clean_document_task.IndexProcessorFactory") as processor_factory, + patch("tasks.batch_clean_document_task.schedule_billing_vector_space_refresh") as schedule_refresh, + ): + processor_factory.return_value.init_index_processor.return_value.clean.side_effect = RuntimeError( + "vector cleanup failed" + ) + batch_clean_document_task( + document_ids=["document-1"], + dataset_id="dataset-1", + doc_form="paragraph", + file_ids=[], + ) + + schedule_refresh.assert_not_called() diff --git a/api/tests/unit_tests/tasks/test_clean_dataset_task.py b/api/tests/unit_tests/tasks/test_clean_dataset_task.py index 826276086ba..4a2dcc44145 100644 --- a/api/tests/unit_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/unit_tests/tasks/test_clean_dataset_task.py @@ -434,14 +434,15 @@ class TestIndexProcessorParameters: index_struct = '{"type": "paragraph"}' # Act - clean_dataset_task( - dataset_id=dataset_id, - tenant_id=tenant_id, - indexing_technique=indexing_technique, - index_struct=index_struct, - collection_binding_id=collection_binding_id, - doc_form=IndexStructureType.PARAGRAPH_INDEX, - ) + with patch("tasks.clean_dataset_task.schedule_billing_vector_space_refresh") as schedule_refresh: + clean_dataset_task( + dataset_id=dataset_id, + tenant_id=tenant_id, + indexing_technique=indexing_technique, + index_struct=index_struct, + collection_binding_id=collection_binding_id, + doc_form=IndexStructureType.PARAGRAPH_INDEX, + ) # Assert mock_index_processor_factory["processor"].clean.assert_called_once() @@ -462,3 +463,28 @@ class TestIndexProcessorParameters: assert call_args[1]["session"] is mock_db_session.session assert call_args[1]["with_keywords"] is True assert call_args[1]["delete_child_chunks"] is True + schedule_refresh.assert_called_once_with(tenant_id) + + def test_vector_cleanup_failure_does_not_schedule_billing_refresh( + self, + dataset_id: str, + tenant_id: str, + collection_binding_id: str, + mock_db_session, + mock_storage, + mock_index_processor_factory, + mock_get_image_upload_file_ids, + ): + mock_index_processor_factory["processor"].clean.side_effect = RuntimeError("vector cleanup failed") + + with patch("tasks.clean_dataset_task.schedule_billing_vector_space_refresh") as schedule_refresh: + clean_dataset_task( + dataset_id=dataset_id, + tenant_id=tenant_id, + indexing_technique=IndexTechniqueType.HIGH_QUALITY, + index_struct='{"type": "paragraph"}', + collection_binding_id=collection_binding_id, + doc_form=IndexStructureType.PARAGRAPH_INDEX, + ) + + schedule_refresh.assert_not_called() diff --git a/api/tests/unit_tests/tasks/test_clean_document_task.py b/api/tests/unit_tests/tasks/test_clean_document_task.py index 26d7b3e3b6b..2f517ce1ba4 100644 --- a/api/tests/unit_tests/tasks/test_clean_document_task.py +++ b/api/tests/unit_tests/tasks/test_clean_document_task.py @@ -169,12 +169,13 @@ class TestVectorCleanupResilience: ) # Act — must not raise out of the task even though clean() raises. - clean_document_task( - document_id=document_id, - dataset_id=dataset_id, - doc_form="paragraph", - file_id=None, - ) + with patch("tasks.clean_document_task.schedule_billing_vector_space_refresh") as schedule_refresh: + clean_document_task( + document_id=document_id, + dataset_id=dataset_id, + doc_form="paragraph", + file_id=None, + ) # Assert # 1. Vector cleanup was attempted. @@ -187,6 +188,7 @@ class TestVectorCleanupResilience: "Step 3+ DB cleanup did not run after vector cleanup failure; " "this regression would re-introduce the orphan-segment bug." ) + schedule_refresh.assert_not_called() def test_vector_cleanup_success_path_remains_unaffected( self, @@ -229,12 +231,13 @@ class TestVectorCleanupResilience: mock_sf.create_session.side_effect = [cm1, cm2] + [_default_cm() for _ in range(10)] - clean_document_task( - document_id=document_id, - dataset_id=dataset_id, - doc_form="paragraph", - file_id=None, - ) + with patch("tasks.clean_document_task.schedule_billing_vector_space_refresh") as schedule_refresh: + clean_document_task( + document_id=document_id, + dataset_id=dataset_id, + doc_form="paragraph", + file_id=None, + ) assert mock_index_processor_factory["processor"].clean.call_count == 1 # Index cleanup invoked with the expected delete_summaries / delete_child_chunks flags. @@ -242,6 +245,7 @@ class TestVectorCleanupResilience: assert kwargs.get("with_keywords") is True assert kwargs.get("delete_child_chunks") is True assert kwargs.get("delete_summaries") is True + schedule_refresh.assert_called_once_with(tenant_id) def test_no_segments_skips_vector_cleanup( self, @@ -279,13 +283,15 @@ class TestVectorCleanupResilience: mock_sf.create_session.side_effect = [cm1] + [_default_cm() for _ in range(10)] - clean_document_task( - document_id=document_id, - dataset_id=dataset_id, - doc_form="paragraph", - file_id=None, - ) + with patch("tasks.clean_document_task.schedule_billing_vector_space_refresh") as schedule_refresh: + clean_document_task( + document_id=document_id, + dataset_id=dataset_id, + doc_form="paragraph", + file_id=None, + ) # Vector cleanup is gated on ``index_node_ids``; when there are no # segments the IndexProcessorFactory path is never entered. mock_index_processor_factory["factory_cls"].assert_not_called() + schedule_refresh.assert_not_called() diff --git a/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py b/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py new file mode 100644 index 00000000000..8f4820be660 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py @@ -0,0 +1,45 @@ +from unittest.mock import patch + +import pytest + +from tasks.refresh_billing_vector_space_task import ( + refresh_billing_vector_space_task, + schedule_billing_vector_space_refresh, +) + + +def test_refresh_invalidates_vector_space_cache(): + with ( + patch("tasks.refresh_billing_vector_space_task.dify_config.BILLING_ENABLED", True), + patch( + "tasks.refresh_billing_vector_space_task.BillingService.invalidate_vector_space_cache" + ) as invalidate_cache, + ): + refresh_billing_vector_space_task.run("tenant-1") + + invalidate_cache.assert_called_once_with("tenant-1") + + +def test_refresh_failure_schedules_retry(): + error = RuntimeError("billing unavailable") + + with ( + patch("tasks.refresh_billing_vector_space_task.dify_config.BILLING_ENABLED", True), + patch( + "tasks.refresh_billing_vector_space_task.BillingService.invalidate_vector_space_cache", + side_effect=error, + ), + patch.object(refresh_billing_vector_space_task, "retry", side_effect=RuntimeError("retry scheduled")) as retry, + pytest.raises(RuntimeError, match="retry scheduled"), + ): + refresh_billing_vector_space_task.run("tenant-1") + + retry.assert_called_once_with(exc=error, countdown=30) + + +def test_dispatch_failure_does_not_propagate(): + with ( + patch("tasks.refresh_billing_vector_space_task.dify_config.BILLING_ENABLED", True), + patch.object(refresh_billing_vector_space_task, "delay", side_effect=RuntimeError("broker unavailable")), + ): + schedule_billing_vector_space_refresh("tenant-1") From acb5ee29e18edccda72768cfc3e425b0f4f1f204 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:48:37 +0800 Subject: [PATCH 31/63] test(e2e): validate generated console contracts (#39400) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../skills/e2e-cucumber-playwright/SKILL.md | 13 +- api/controllers/console/agent/roster.py | 1 + api/controllers/console/workspace/plugin.py | 10 + api/models/agent_config_entities.py | 30 +- api/openapi/markdown/console-openapi.md | 11 +- .../models/test_agent_config_entities.py | 17 + e2e/AGENTS.md | 16 +- e2e/features/agent-v2/AGENTS.md | 28 +- e2e/features/agent-v2/support/access-point.ts | 75 +- .../agent-v2/support/agent-build-draft.ts | 76 +- e2e/features/agent-v2/support/agent-drive.ts | 258 +++---- e2e/features/agent-v2/support/agent.ts | 164 ++--- .../agent-v2/support/fixtures/access.ts | 60 +- .../agent-v2/support/fixtures/agents.ts | 353 +++++----- .../agent-v2/support/fixtures/common.ts | 50 +- .../agent-v2/support/fixtures/datasets.ts | 130 ++-- .../agent-v2/support/fixtures/models.ts | 112 ++- .../agent-v2/support/fixtures/tools.ts | 50 +- e2e/features/agent-v2/support/seed.ts | 648 ++++++++---------- e2e/features/agent-v2/support/workflow.ts | 85 ++- e2e/features/agent-v2/tools.feature | 10 - .../access-point-service-api.steps.ts | 14 +- .../agent-v2/access-point-web-app.steps.ts | 11 +- .../agent-v2/access-point-workflow.steps.ts | 2 +- .../agent-v2/access-point.steps.ts | 15 +- .../agent-v2/agent-edit.steps.ts | 30 +- .../agent-v2/agent-roster.steps.ts | 4 +- .../agent-v2/build-draft.steps.ts | 65 +- .../agent-v2/configure-helpers.ts | 34 +- .../agent-v2/configure.steps.ts | 71 +- .../agent-v2/env-editor.steps.ts | 14 +- .../agent-v2/fixtures.steps.ts | 45 +- .../agent-v2/knowledge.steps.ts | 14 +- .../agent-v2/output-variables.steps.ts | 16 +- .../agent-v2/publish.steps.ts | 23 +- .../agent-v2/speech-to-text.steps.ts | 2 +- .../step-definitions/agent-v2/tools.steps.ts | 52 +- .../agent-v2/workflow-node.steps.ts | 18 +- .../step-definitions/apps/create-app.steps.ts | 3 +- .../apps/duplicate-app.steps.ts | 5 +- .../step-definitions/apps/share-app.steps.ts | 16 +- .../apps/switch-app-mode.steps.ts | 2 +- .../apps/web-app-service.steps.ts | 16 +- .../apps/workflow-run.steps.ts | 2 +- .../step-definitions/common/app.steps.ts | 8 +- e2e/features/support/hooks.ts | 49 +- e2e/features/support/world.ts | 24 +- e2e/fixtures/auth.ts | 101 +-- e2e/package.json | 3 + e2e/scripts/seed.ts | 5 + e2e/support/api/apps.ts | 36 +- e2e/support/api/console-client.ts | 52 ++ e2e/support/api/console-context.ts | 34 - e2e/support/api/console-session.ts | 16 + e2e/support/api/datasets.ts | 11 - e2e/support/api/marketplace-plugins.ts | 122 ---- e2e/support/api/playwright-fetch.ts | 61 ++ e2e/support/api/tools.ts | 24 - e2e/support/api/web-apps.ts | 35 +- e2e/support/api/workflows.ts | 171 ++--- e2e/support/marketplace-plugins.ts | 102 ++- e2e/support/seed.ts | 2 + e2e/tests/console-client.test.ts | 208 ++++++ e2e/tests/marketplace-plugins.test.ts | 108 +++ packages/contracts/binary-zod.test.ts | 15 + .../generated/api/console/agent/types.gen.ts | 12 +- .../generated/api/console/agent/zod.gen.ts | 14 +- .../generated/api/console/apps/types.gen.ts | 8 +- .../generated/api/console/apps/zod.gen.ts | 14 +- .../generated/api/console/files/zod.gen.ts | 2 +- .../api/console/installed-apps/zod.gen.ts | 4 +- .../api/console/snippets/types.gen.ts | 8 +- .../generated/api/console/snippets/zod.gen.ts | 8 +- .../api/console/trial-apps/zod.gen.ts | 6 +- .../api/console/workspaces/orpc.gen.ts | 2 + .../api/console/workspaces/types.gen.ts | 4 +- .../api/console/workspaces/zod.gen.ts | 10 +- .../generated/api/service/zod.gen.ts | 36 +- packages/contracts/openapi-ts.api.config.ts | 11 +- packages/contracts/package.json | 2 +- .../contracts/sandbox-contract.smoke.test.ts | 36 +- pnpm-lock.yaml | 32 +- .../__tests__/tool-browser.spec.tsx | 98 ++- 83 files changed, 2122 insertions(+), 2043 deletions(-) create mode 100644 e2e/support/api/console-client.ts delete mode 100644 e2e/support/api/console-context.ts create mode 100644 e2e/support/api/console-session.ts delete mode 100644 e2e/support/api/datasets.ts delete mode 100644 e2e/support/api/marketplace-plugins.ts create mode 100644 e2e/support/api/playwright-fetch.ts delete mode 100644 e2e/support/api/tools.ts create mode 100644 e2e/tests/console-client.test.ts create mode 100644 e2e/tests/marketplace-plugins.test.ts create mode 100644 packages/contracts/binary-zod.test.ts diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index 75e79ea2e7f..5762bf2076d 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -32,12 +32,11 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - `e2e/` uses Cucumber for scenarios and Playwright as the browser layer. - `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions. - Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps. -- Browser session behavior comes from `features/support/hooks.ts`: - - default: authenticated session with shared storage state - - `@unauthenticated`: clean browser context - - `@authenticated`: readability/selective-run tag only unless implementation changes - - `@fresh`: only for `e2e:full*` flows +- Treat `e2e/AGENTS.md`, `features/support/hooks.ts`, and the Cucumber configuration as the owners of current session and tag semantics. Verify them when behavior depends on session state instead of copying a tag inventory into this skill. - Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture. +- Perform the behavior under test through Playwright. APIs are allowed for setup, seed preparation, persistence polling, and cleanup, but ordinary Console JSON and representable multipart operations must use the scenario- or process-owned generated oRPC client with request and response validation enabled. Keep the setup/cleanup API identity independent from an unauthenticated or logged-out behavior browser. +- Consume generated operations directly. Do not add one-to-one API wrappers, handwritten endpoint URLs, response DTO casts, duplicate schemas, global mutable clients, or TanStack Query caching in Cucumber. Keep helpers only for real fixture construction, multi-operation orchestration, invariants, polling, derived test views, or protocol adapters. +- Keep SSE, binary, redirect-only, external-service, and readiness exceptions centralized under their protocol owner. A contract mismatch must fail and be fixed at the backend schema owner followed by regeneration; never weaken validation to make E2E pass. ## Workflow @@ -66,7 +65,7 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id. 5. Validate narrowly. - Run the narrowest tagged scenario or flow that exercises the change. - - Run `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`. + - Run the package-required static checks documented in `e2e/AGENTS.md`. - Broaden verification only when the change affects hooks, tags, setup, or shared step semantics. ## Review Checklist @@ -77,6 +76,8 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - Are locators user-facing and assertions web-first? - Does the change introduce hidden coupling across scenarios, tags, or instance state? - Does it document or implement behavior that differs from the real hooks or configuration? +- Does setup/cleanup use the generated client directly, with any remaining helper owning more than a one-to-one endpoint forward? +- Is every raw HTTP call a documented protocol or infrastructure exception rather than an ordinary Console operation? Lead findings with correctness, flake risk, and architecture drift. diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 744378e2383..c06a5710cdd 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -729,6 +729,7 @@ class AgentBuildDraftCheckoutApi(Resource): @console_ns.route("/agent//build-draft") class AgentBuildDraftApi(Resource): @console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__]) + @console_ns.response(404, "Agent build draft not found") @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index d87c1a99b0a..33a4f54e69b 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -67,6 +67,15 @@ from services.plugin.plugin_parameter_service import PluginParameterService from services.plugin.plugin_permission_service import PluginPermissionService from services.tools.tools_transform_service import ToolTransformService +_PLUGIN_PACKAGE_UPLOAD_PARAMS = { + "pkg": { + "description": "Plugin package to upload", + "in": "formData", + "type": "file", + "required": True, + } +} + class AutoUpgradeSettingsResponse(TypedDict): strategy_setting: TenantPluginAutoUpgradeStrategySetting @@ -645,6 +654,7 @@ class PluginAssetApi(Resource): @console_ns.route("/workspaces/current/plugin/upload/pkg") class PluginUploadFromPkgApi(Resource): + @console_ns.doc(consumes=["multipart/form-data"], params=_PLUGIN_PACKAGE_UPLOAD_PARAMS) @console_ns.response(200, "Success", console_ns.models[PluginDecodeResponse.__name__]) @setup_required @login_required diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index a47129dcc20..a6a0cd544a2 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -44,18 +44,28 @@ _DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = { }, "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "required": {"type": "boolean"}, - "file": {"type": "object", "additionalProperties": True}, + "file": { + "anyOf": [ + {"type": "object", "additionalProperties": True}, + {"type": "null"}, + ] + }, "array_item": { - "type": "object", - "additionalProperties": True, - "properties": { - "type": { - "type": "string", - "enum": [item.value for item in DeclaredOutputType], + "anyOf": [ + { + "type": "object", + "additionalProperties": True, + "properties": { + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, }, - "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, - }, + {"type": "null"}, + ] }, "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, }, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 7b3a11ccf91..bc3768f4ae1 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -531,6 +531,7 @@ Run a build-draft Agent App turn that asks the agent to push config updates | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| +| 404 | Agent build draft not found | | ### [PUT] /agent/{agent_id}/build-draft #### Parameters @@ -11332,6 +11333,12 @@ Returns permission flags that control workspace features like member invitations | 200 | Success | **application/json**: [PluginDecodeResponse](#plugindecoderesponse)
| ### [POST] /workspaces/current/plugin/upload/pkg +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"pkg"**: binary }
| + #### Responses | Code | Description | Schema | @@ -17176,7 +17183,7 @@ about. Stage 4 §4.2. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | +| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | @@ -17205,7 +17212,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards. | ---- | ---- | ----------- | -------- | | array_item | [DeclaredArrayItem](#declaredarrayitem) | | No | | check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No | -| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | +| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | | file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py index 5538a1981de..d95b7130f5c 100644 --- a/api/tests/unit_tests/models/test_agent_config_entities.py +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -139,6 +139,23 @@ def test_declared_output_child_validates_shape_and_defaults() -> None: ) +def test_declared_output_child_schema_matches_nullable_serialization() -> None: + config = DeclaredOutputConfig( + name="response", + type=DeclaredOutputType.OBJECT, + children=[DeclaredOutputChildConfig(name="text", type=DeclaredOutputType.STRING)], + ) + child = config.model_dump(mode="json")["children"][0] + + assert child["file"] is None + assert child["array_item"] is None + + children_schema = DeclaredOutputConfig.model_json_schema(mode="serialization")["properties"]["children"] + child_properties = children_schema["items"]["properties"] + assert {"type": "null"} in child_properties["file"]["anyOf"] + assert {"type": "null"} in child_properties["array_item"]["anyOf"] + + def test_declared_output_validates_shape_and_defaults() -> None: file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) assert file_output.file is not None diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 8771bd27d68..abbf2a27b4b 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -81,7 +81,7 @@ flowchart TD A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"] B --> C["support/web-server.ts starts or reuses frontend directly"] C --> D["Cucumber loads config, steps, and support modules"] - D --> E["BeforeAll bootstraps shared auth state via /install"] + D --> E["The first Before hook lazily bootstraps shared auth state"] E --> F{"Which command is running?"} F -->|`pnpm -C e2e e2e`| G["Run deterministic scenarios; exclude @prepared and external runtime"] F -->|`pnpm -C e2e e2e:full*`| H["Reset and run deterministic scenarios; exclude @prepared and external runtime"] @@ -96,7 +96,7 @@ Ownership is split like this: - `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation - `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown - `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics -- `features/support/world.ts` owns per-scenario typed context +- `features/support/world.ts` owns the per-scenario behavior BrowserContext and authenticated setup/cleanup client; their identities remain separate so unauthenticated and logout journeys cannot invalidate fixture ownership - `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root Package layout: @@ -353,6 +353,18 @@ Keep package-level support limited to broadly reusable primitives such as API cl Use generated API contracts for Console/Web/Service API request, response, and payload shapes. Import the concrete type directly from `@dify/contracts/.../types.gen` when it exists, and do not hand-write duplicate response shapes or wrap generated types in local aliases just to preserve an older helper name. Keep local E2E types only for scenario state, fixture registries, helper input options, and intentionally narrowed test view models that are not complete API responses. +### Console API and protocol boundaries + +The action under test belongs to the browser. `When` steps must use Playwright to perform the user action; do not replace the action with an API request. `Given` setup, seed preparation, persistence polling, and `After` cleanup may use APIs when that makes the scenario faster and more deterministic. `Then` should prefer a user-observable browser result; an API read is appropriate only when persistence itself is the asserted contract and the endpoint owns that state. + +For ordinary Console JSON operations and multipart uploads represented by Console OpenAPI, use the generated oRPC router with generated request and response validation enabled. A scenario client belongs to its `DifyWorld` and uses a scenario-owned authenticated request context that is independent from the behavior browser; seed processes own a standalone client for their process lifetime. Do not create a mutable cross-scenario API client, add TanStack Query caching to Cucumber, hand-write Console endpoint URLs, cast response JSON to an API DTO, or duplicate a generated Zod schema. When a browser action's captured response must provide an ID for cleanup, parse it with the generated response schema. + +Do not add a helper that only renames or forwards one generated operation. Call the generated client directly from the owning step, hook, or fixture orchestration. Keep a helper only when it owns a real test concern such as constructing a valid domain fixture, coordinating multiple operations, maintaining an invariant or cleanup registry, polling eventual consistency, deriving a narrowed test view, or adapting a non-OpenAPI protocol. + +SSE/event streams, binary downloads, redirect-only flows, external services, and infrastructure health/readiness checks may use a dedicated protocol adapter. Keep each exception centralized under its real owner and continue to use generated payload types where the contract covers the request. Multipart is not an exception merely because it carries a file: fix the backend OpenAPI schema and regenerate when the operation can be represented. + +Request or response validation failures are contract failures. Do not suppress them with casts, permissive fallback schemas, disabled validation, swallowed cleanup errors, or a second handwritten request path. Trace the mismatch to the endpoint's backend schema owner, update it according to `api/controllers/API_SCHEMA_GUIDE.md`, regenerate `@dify/contracts`, and keep the E2E assertion aligned with the product's real state owner rather than an internal backing resource. + Use typed cleanup fields on `DifyWorld` for resource types created by scenarios, and use `DifyWorld.registerCleanup(...)` when a scenario creates any resource type that is not covered by typed cleanup fields. Typed cleanup should remove child or referencing resources before their owners, such as Agent files before Agents and workflow apps before Agents they reference. Cleanup failures should be attached to the report instead of being swallowed silently. Cleanup callbacks run after typed cleanup queues, even when the scenario fails. Scenario-owned setup may create disposable apps, Agents, files, credentials, drafts, or access toggles when the scenario owns their lifecycle and cleanup. Do not use scenario setup to silently fix a shared fixture; a missing or drifted fixed resource is a seed failure. diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 013af12d1e0..ab8a20a53c2 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -25,16 +25,7 @@ Use `@external-model` and `@external-tool` only for runtime calls. A scenario th ## Step organization -Keep steps grouped by user capability: - -- `configure.steps.ts` — navigation, editing, autosave, and saved draft behavior. -- `build-draft.steps.ts` — checkout, apply, discard, and isolation. -- `files.steps.ts`, `knowledge.steps.ts`, `tools.steps.ts` — resource configuration behavior. -- `advanced-settings.steps.ts`, `env-editor.steps.ts` — supported Advanced Settings behavior. -- `agent-roster.steps.ts`, `agent-edit.steps.ts`, `publish.steps.ts` — Agent lifecycle surfaces. -- `access-point*.steps.ts` — Web app, service API, and Workflow access. -- `fixtures.steps.ts` — strict fixture resolution for behavior scenarios. -- `speech-to-text.steps.ts` — voice input and transcription behavior. +Keep steps grouped by Agent product capability, such as configuration, Build draft, resource configuration, lifecycle, Access Point, and runtime behavior. Group by the domain action that owns the wording instead of mechanically pairing a step file with each feature file. Fixture-resolution steps should remain separate from behavior steps because they validate environment readiness rather than perform a user journey. Cucumber step definitions are globally registered. Do not duplicate step text across files. @@ -66,20 +57,11 @@ pnpm -C e2e e2e:post-merge:prepare pnpm -C e2e e2e:post-merge ``` -The strict seed must finish without blocked tasks. It prepares the stable and decision models, Speech-to-Text default, marketplace plugins, JSON Replace and Tavily tools, ready knowledge base, Full Config Agent, Tool States Agent, Dual Retrieval Agent, and Workflow reference. +The strict seed must finish without blocked tasks. The concrete resource inventory and defaults belong to the seed profile and environment configuration rather than this guidance. -Fixture helpers live under `features/agent-v2/support/fixtures/`: +Organize fixture helpers by the product resource or infrastructure capability they own, not by the feature file that happens to consume them. Keep runtime readiness adapters separate from Console resource fixtures, and keep all fixture state in the current `SeedContext` or scenario `DifyWorld` rather than module globals. -- `models.ts` — stable, decision, and Speech-to-Text models. -- `agents.ts` — fixed Agent and configuration contracts. -- `datasets.ts` — indexed knowledge contract. -- `tools.ts` — installed built-in tool contract. -- `access.ts` — Workflow reference contract. -- `agent-backend.ts` — runtime server and shellctl readiness. - -The stable model selectors default to `openai` / `gpt-5-nano` / `llm`. The decision model defaults to `openai` / `gpt-5.5` / `llm`. The Speech-to-Text model defaults to `openai` / `gpt-4o-mini-transcribe`. Provider credentials belong to seed/admin setup through `E2E_MODEL_PROVIDER_CREDENTIALS_JSON`, never to Cucumber steps. - -The Full Config Agent contract includes the stable model, prompt marker, checked-in files, Summary Skill, JSON Replace tool, and indexed knowledge reference. Tool States includes Summary Skill, JSON Replace, Tavily, and its credential reference. Dual Retrieval includes generated-query and custom-query knowledge sets. Workflow reference verifies the same Console API used by the Access Point table. +Provider credentials belong to seed/admin setup, never to Cucumber steps. ## Runtime contract @@ -94,3 +76,5 @@ Build mode covers Configure and Build draft persistence. Preview/Test Run covers ## API contracts Import generated Console/Web/Service API types directly from `@dify/contracts/.../types.gen`. Keep local types only for E2E-owned state, fixture registry entries, helper inputs, and intentionally narrowed views. If the generated contract is incomplete, fix the backend schema and regenerate it instead of duplicating the response shape. + +Agent detail is the state owner for Agent scenarios. An Agent's backing app identifier may be used to route a shared app command, but it is not a substitute query model and must not become the final assertion source. Derive Agent Web app URLs and persisted Agent state from the generated Agent detail contract, then assert the user-visible Access Point or runtime result in the browser. diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index 4d5dea98973..575cef3e662 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -1,23 +1,10 @@ -import type { - AgentApiAccessResponse, - AgentApiStatusPayload, - ApiKeyItem, -} from '@dify/contracts/api/console/agent/types.gen' -import type { - ChatRequestPayloadWithUser, - PostChatMessagesResponse, -} from '@dify/contracts/api/service/types.gen' -import { - zPostAgentByAgentIdApiEnableResponse, - zPostAgentByAgentIdApiKeysResponse, -} from '@dify/contracts/api/console/agent/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' -import { setAppSiteEnabled } from '../../../support/api/web-apps' -import { getTestAgent } from './agent' +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import type { ChatRequestPayloadWithUser } from '@dify/contracts/api/service/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import { consumeServiceApiSse, SERVICE_API_STREAM_TIMEOUT_MS } from './service-api-sse' export type AgentServiceApiChatResult = { - body: PostChatMessagesResponse | unknown + body: unknown ok: boolean status: number } @@ -44,43 +31,27 @@ async function parseServiceApiChatResponse(response: Response) { } } -export async function setAgentSiteAccess(agentId: string, enabled: boolean): Promise { - const agent = await getTestAgent(agentId) +export function getAgentWebAppURL(agent: AgentAppDetailWithSite): string { + const token = agent.site?.access_token ?? agent.site?.code + if (!token) throw new Error(`Agent v2 ${agent.id} does not expose a Web app access token.`) + + const baseURL = agent.site?.app_base_url + if (!baseURL) throw new Error(`Agent v2 ${agent.id} does not expose a Web app base URL.`) + + return `${baseURL.replace(/\/$/, '')}/agent/${token}` +} + +export async function enableAgentWebApp(client: ConsoleClient, agentId: string): Promise { + const agent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) const appId = agent.app_id ?? agent.backing_app_id if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) - await setAppSiteEnabled(appId, enabled) -} - -export async function setAgentApiAccess( - agentId: string, - enabled: boolean, -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { enable_api: enabled } satisfies AgentApiStatusPayload - const response = await ctx.post(`/console/api/agent/${agentId}/api-enable`, { - data, - }) - await expectApiResponseOK( - response, - `${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`, - ) - return zPostAgentByAgentIdApiEnableResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function createAgentApiKey(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`) - await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`) - return zPostAgentByAgentIdApiKeysResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + await client.apps.byAppId.siteEnable.post({ + body: { enable_site: true }, + params: { app_id: appId }, + }) + const updatedAgent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) + return getAgentWebAppURL(updatedAgent) } export async function sendAgentServiceApiChatMessage({ @@ -114,7 +85,7 @@ export async function sendAgentServiceApiChatMessage({ const responseBody = await parseServiceApiChatResponse(response) return { - body: responseBody as PostChatMessagesResponse | unknown, + body: responseBody, ok: response.ok, status: response.status, } diff --git a/e2e/features/agent-v2/support/agent-build-draft.ts b/e2e/features/agent-v2/support/agent-build-draft.ts index 6b1405654cb..7946739ac53 100644 --- a/e2e/features/agent-v2/support/agent-build-draft.ts +++ b/e2e/features/agent-v2/support/agent-build-draft.ts @@ -2,71 +2,33 @@ import type { AgentBuildDraftResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' - -export async function checkoutAgentBuildDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/checkout`, { - data: { force: true }, - }) - await expectApiResponseOK(response, `Checkout Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } -} +import type { ConsoleClient } from '../../../support/api/console-client' +import { ORPCError } from '@orpc/client' export async function saveAgentBuildDraft( + client: ConsoleClient, agentId: string, agentSoul: AgentSoulConfig, ): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.put(`/console/api/agent/${agentId}/build-draft`, { - data: { - agent_soul: agentSoul, - save_strategy: 'save_to_current_version', - variant: 'agent_app', - }, - }) - await expectApiResponseOK(response, `Save Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } + return client.agent.byAgentId.buildDraft.put({ + body: { + agent_soul: agentSoul, + save_strategy: 'save_to_current_version', + variant: 'agent_app', + }, + params: { agent_id: agentId }, + }) } -export async function agentBuildDraftExists(agentId: string): Promise { - const ctx = await createConsoleApiContext() +export async function agentBuildDraftExists( + client: ConsoleClient, + agentId: string, +): Promise { try { - const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - if (response.status() === 404) return false - - await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) + await client.agent.byAgentId.buildDraft.get({ params: { agent_id: agentId } }) return true - } finally { - await ctx.dispose() - } -} - -export async function getAgentBuildDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } -} - -export async function discardAgentBuildDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`) - await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`) - } finally { - await ctx.dispose() + } catch (error) { + if (error instanceof ORPCError && error.status === 404) return false + throw error } } diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/agent-drive.ts index 1b0fe20e189..c76e7b75c9b 100644 --- a/e2e/features/agent-v2/support/agent-drive.ts +++ b/e2e/features/agent-v2/support/agent-drive.ts @@ -7,17 +7,10 @@ import type { AgentDriveSkillListResponse, AgentSkillUploadResponse, } from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import { Buffer } from 'node:buffer' import { readFile } from 'node:fs/promises' import path from 'node:path' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' - -export type UploadedConsoleFile = { - id: string - mime_type?: string | null - name: string - size?: number | null -} const crc32Table = new Uint32Array(256) for (let i = 0; i < crc32Table.length; i++) { @@ -117,167 +110,98 @@ const toSkillArchiveUpload = async ({ } } -export async function uploadAgentDriveSkill({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createConsoleApiContext() - try { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - const response = await ctx.post(`/console/api/agent/${agentId}/skills/upload`, { - multipart: { - file: { - buffer: upload.buffer, - mimeType: 'application/zip', - name: upload.name, - }, - }, +const createUploadFile = (content: Buffer, name: string, type: string) => + new File([Uint8Array.from(content)], name, { type }) + +export async function uploadAgentDriveSkill( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const upload = await toSkillArchiveUpload({ fileName, filePath }) + return client.agent.byAgentId.skills.upload.post({ + body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, + params: { agent_id: agentId }, + }) +} + +export async function uploadAgentConfigFileToDraft( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const uploadedFile = await client.files.upload.post({ + body: { file: createUploadFile(await readFile(filePath), fileName, 'text/plain') }, + }) + const body: AgentConfigFileUploadResponse = await client.agent.byAgentId.config.files.post({ + body: { upload_file_id: uploadedFile.id }, + params: { agent_id: agentId }, + }) + const file = body.file + if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) + + return { + file_id: file.file_id, + file_kind: 'upload_file', + hash: file.hash, + mime_type: file.mime_type, + name: file.name, + size: file.size, + } +} + +export async function uploadAgentConfigSkillToDraft( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const upload = await toSkillArchiveUpload({ fileName, filePath }) + const body: AgentConfigSkillUploadResponse = + await client.agent.byAgentId.config.skills.upload.post({ + body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, + params: { agent_id: agentId }, }) - await expectApiResponseOK(response, `Upload Agent v2 drive skill ${fileName} for ${agentId}`) - return (await response.json()) as AgentSkillUploadResponse - } finally { - await ctx.dispose() + const skill = body.skill + if (!skill.file_id) throw new Error(`Agent v2 config skill ${fileName} did not return a file_id.`) + + return { + description: skill.description, + file_id: skill.file_id, + file_kind: 'tool_file', + hash: skill.hash, + mime_type: skill.mime_type, + name: skill.name, + size: skill.size, } } -export async function uploadAgentConfigFileToDraft({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createConsoleApiContext() - try { - const uploadResponse = await ctx.post('/console/api/files/upload', { - multipart: { - file: { - buffer: await readFile(filePath), - mimeType: 'text/plain', - name: fileName, - }, - }, - }) - await expectApiResponseOK(uploadResponse, `Upload Agent v2 config source file ${fileName}`) - const uploadedFile = (await uploadResponse.json()) as UploadedConsoleFile - - const commitResponse = await ctx.post(`/console/api/agent/${agentId}/config/files`, { - data: { - upload_file_id: uploadedFile.id, - }, - }) - await expectApiResponseOK( - commitResponse, - `Commit Agent v2 config file ${fileName} for ${agentId}`, - ) - const body = (await commitResponse.json()) as AgentConfigFileUploadResponse - const file = body.file - if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) - - return { - file_id: file.file_id, - file_kind: 'upload_file', - hash: file.hash, - mime_type: file.mime_type, - name: file.name, - size: file.size, - } - } finally { - await ctx.dispose() - } -} - -export async function uploadAgentConfigSkillToDraft({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createConsoleApiContext() - try { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - const response = await ctx.post(`/console/api/agent/${agentId}/config/skills/upload`, { - multipart: { - file: { - buffer: upload.buffer, - mimeType: 'application/zip', - name: upload.name, - }, - }, - }) - await expectApiResponseOK(response, `Upload Agent v2 config skill ${fileName} for ${agentId}`) - const body = (await response.json()) as AgentConfigSkillUploadResponse - const skill = body.skill - if (!skill.file_id) - throw new Error(`Agent v2 config skill ${fileName} did not return a file_id.`) - - return { - description: skill.description, - file_id: skill.file_id, - file_kind: 'tool_file', - hash: skill.hash, - mime_type: skill.mime_type, - name: skill.name, - size: skill.size, - } - } finally { - await ctx.dispose() - } -} - -export async function getAgentDriveSkills(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/drive/skills`) - await expectApiResponseOK(response, `Get Agent v2 drive skills for ${agentId}`) - const body = (await response.json()) as AgentDriveSkillListResponse - return body.items ?? [] - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentConfigFile(agentId: string, name: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete( - `/console/api/agent/${agentId}/config/files/${encodeURIComponent(name)}`, - ) - await expectApiResponseOK(response, `Delete Agent v2 config file ${name} for ${agentId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentConfigSkill(agentId: string, name: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete( - `/console/api/agent/${agentId}/config/skills/${encodeURIComponent(name)}`, - ) - await expectApiResponseOK(response, `Delete Agent v2 config skill ${name} for ${agentId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentDriveFile(agentId: string, key: string): Promise { - const ctx = await createConsoleApiContext() - try { - const searchParams = new URLSearchParams({ key }) - const response = await ctx.delete(`/console/api/agent/${agentId}/files?${searchParams}`) - await expectApiResponseOK(response, `Delete Agent v2 drive file ${key} for ${agentId}`) - } finally { - await ctx.dispose() - } +export async function getAgentDriveSkills( + client: ConsoleClient, + agentId: string, +): Promise { + const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({ + params: { agent_id: agentId }, + }) + return body.items ?? [] } diff --git a/e2e/features/agent-v2/support/agent.ts b/e2e/features/agent-v2/support/agent.ts index dfcd407f754..c2fa98ea081 100644 --- a/e2e/features/agent-v2/support/agent.ts +++ b/e2e/features/agent-v2/support/agent.ts @@ -6,11 +6,7 @@ import type { AgentReferencingWorkflowsResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { - zGetAgentByAgentIdResponse, - zPostAgentResponse, -} from '@dify/contracts/api/console/agent/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' +import type { ConsoleClient } from '../../../support/api/console-client' import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming' import { createPublishableAgentSoulConfig, @@ -27,135 +23,87 @@ export type CreateTestAgentOptions = { export const getAgentConfigurePath = (agentId: string) => `/agents/${agentId}/configure` export const getAgentAccessPath = (agentId: string) => `/agents/${agentId}/access` -export async function createTestAgent({ - description = 'Created by Dify E2E.', - name = createE2EResourceName('Agent'), - role = 'E2E test assistant', -}: CreateTestAgentOptions = {}): Promise { +export async function createTestAgent( + client: ConsoleClient, + { + description = 'Created by Dify E2E.', + name = createE2EResourceName('Agent'), + role = 'E2E test assistant', + }: CreateTestAgentOptions = {}, +): Promise { assertE2EResourceName(name, 'Agent') - const ctx = await createConsoleApiContext() - try { - const data = { - description, - icon: '🤖', - icon_background: '#FFEAD5', - icon_type: 'emoji', - name, - role, - } satisfies AgentAppCreatePayload - const response = await ctx.post('/console/api/agent', { - data, - }) - await expectApiResponseOK(response, 'Create Agent v2 test agent') - return zPostAgentResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + const body = { + description, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + role, + } satisfies AgentAppCreatePayload + + return client.agent.post({ body }) } -export async function createConfiguredTestAgent({ - agentSoul = normalAgentSoulConfig, - seed, -}: { - agentSoul?: AgentSoulConfig - seed?: CreateTestAgentOptions -} = {}): Promise { - const agent = await createTestAgent(seed) - await saveAgentComposerDraft(agent.id, agentSoul) +export async function createConfiguredTestAgent( + client: ConsoleClient, + { + agentSoul = normalAgentSoulConfig, + seed, + }: { + agentSoul?: AgentSoulConfig + seed?: CreateTestAgentOptions + } = {}, +): Promise { + const agent = await createTestAgent(client, seed) + await saveAgentComposerDraft(client, agent.id, agentSoul) return agent } -export async function getTestAgent(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}`) - await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`) - return zGetAgentByAgentIdResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function deleteTestAgent(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/agent/${agentId}`) - await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`) - } finally { - await ctx.dispose() - } -} - export async function saveAgentComposerDraft( + client: ConsoleClient, agentId: string, agentSoul: AgentSoulConfig = defaultAgentSoulConfig, ): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.put(`/console/api/agent/${agentId}/composer`, { - data: { - agent_soul: agentSoul, - save_strategy: 'save_to_current_version', - variant: 'agent_app', - }, - }) - await expectApiResponseOK(response, `Save Agent v2 composer draft for ${agentId}`) - return (await response.json()) as AgentAppComposerResponse - } finally { - await ctx.dispose() - } + return client.agent.byAgentId.composer.put({ + body: { + agent_soul: agentSoul, + save_strategy: 'save_to_current_version', + variant: 'agent_app', + }, + params: { agent_id: agentId }, + }) } export async function getAgentReferencingWorkflows( + client: ConsoleClient, agentId: string, ): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/referencing-workflows`) - await expectApiResponseOK(response, `Get Agent v2 referencing workflows for ${agentId}`) - const body = (await response.json()) as AgentReferencingWorkflowsResponse - return body.data ?? [] - } finally { - await ctx.dispose() - } + const body: AgentReferencingWorkflowsResponse = + await client.agent.byAgentId.referencingWorkflows.get({ params: { agent_id: agentId } }) + return body.data ?? [] } -export async function getAgentComposerDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/composer`) - await expectApiResponseOK(response, `Get Agent v2 composer draft for ${agentId}`) - return (await response.json()) as AgentAppComposerResponse - } finally { - await ctx.dispose() - } -} - -export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise { - const composer = await getAgentComposerDraft(agentId) +async function ensureAgentComposerDraftIsPublishable( + client: ConsoleClient, + agentId: string, +): Promise { + const composer = await client.agent.byAgentId.composer.get({ params: { agent_id: agentId } }) if (!composer.agent_soul?.model) await saveAgentComposerDraft( + client, agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig), ) } -export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/publish`, { - data: { version_note: versionNote }, - }) - await expectApiResponseOK(response, `Publish Agent v2 test agent ${agentId}`) - } finally { - await ctx.dispose() - } -} - export async function publishAgentWithPublishableDraft( + client: ConsoleClient, agentId: string, versionNote = 'E2E publish', ): Promise { - await ensureAgentComposerDraftIsPublishable(agentId) - await publishAgent(agentId, versionNote) + await ensureAgentComposerDraftIsPublishable(client, agentId) + await client.agent.byAgentId.publish.post({ + body: { version_note: versionNote }, + params: { agent_id: agentId }, + }) } diff --git a/e2e/features/agent-v2/support/fixtures/access.ts b/e2e/features/agent-v2/support/fixtures/access.ts index ba73df40c86..bb4ad8e7206 100644 --- a/e2e/features/agent-v2/support/fixtures/access.ts +++ b/e2e/features/agent-v2/support/fixtures/access.ts @@ -1,51 +1,43 @@ -import type { AgentReferencingWorkflowsResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { requirePreseededAgent, requirePreseededWorkflow } from './agents' import { failFixturePrerequisite } from './common' export async function requirePreseededAgentWorkflowReference( world: DifyWorld, + client: ConsoleClient, agentName: string, workflowName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) - const workflow = await requirePreseededWorkflow(world, workflowName) + const workflow = await requirePreseededWorkflow(world, client, workflowName) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/referencing-workflows`) - await expectApiResponseOK(response, `Check preseeded Agent workflow reference ${agentName}`) - const references = (await response.json()) as AgentReferencingWorkflowsResponse - const reference = references.data?.find( - (item) => item.app_id === workflow.id || item.app_name === workflow.name, + const references = await client.agent.byAgentId.referencingWorkflows.get({ + params: { agent_id: agent.id }, + }) + const reference = references.data?.find( + (item) => item.app_id === workflow.id || item.app_name === workflow.name, + ) + + if (!reference) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is not referenced by workflow "${workflowName}".`, ) + } - if (!reference) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is not referenced by workflow "${workflowName}".`, - ) - } + if (!reference.node_ids || reference.node_ids.length < 1) { + return failFixturePrerequisite( + world, + `Preseeded workflow "${workflowName}" does not expose Agent reference nodes for "${agentName}".`, + ) + } - if (!reference.node_ids || reference.node_ids.length < 1) { - return failFixturePrerequisite( - world, - `Preseeded workflow "${workflowName}" does not expose Agent reference nodes for "${agentName}".`, - ) - } - - return { - id: workflow.id, - kind: 'workflow', - name: workflow.name, - } - } finally { - await ctx.dispose() + return { + id: workflow.id, + kind: 'workflow', + name: workflow.name, } } diff --git a/e2e/features/agent-v2/support/fixtures/agents.ts b/e2e/features/agent-v2/support/fixtures/agents.ts index 303f2e448b6..e0ab6620d97 100644 --- a/e2e/features/agent-v2/support/fixtures/agents.ts +++ b/e2e/features/agent-v2/support/fixtures/agents.ts @@ -1,13 +1,6 @@ -import type { - AgentAppComposerResponse, - AgentDriveSkillListResponse, -} from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -18,9 +11,8 @@ import { asArray, asRecord, asString, - buildQuery, failFixturePrerequisite, - findConsoleResourceByName, + findResourceByName, hasNamedOrKeyedEntry, } from './common' import { requireReadyPreseededDataset } from './datasets' @@ -80,14 +72,13 @@ const hasKnowledgeSet = ( export async function requirePreseededAgent( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const query = buildQuery({ limit: '20', name: resourceName, page: '1' }) - const resource = await findConsoleResourceByName({ - action: `Check preseeded Agent ${resourceName}`, - path: `/console/api/agent?${query}`, - resourceName, + const response = await client.agent.get({ + query: { limit: 20, name: resourceName, page: 1 }, }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded Agent "${resourceName}" was not found.`) @@ -101,14 +92,13 @@ export async function requirePreseededAgent( export async function requirePreseededWorkflow( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const query = buildQuery({ limit: '20', mode: 'workflow', name: resourceName, page: '1' }) - const resource = await findConsoleResourceByName({ - action: `Check preseeded workflow ${resourceName}`, - path: `/console/api/apps?${query}`, - resourceName, + const response = await client.apps.get({ + query: { limit: 20, mode: 'workflow', name: resourceName, page: 1 }, }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded workflow "${resourceName}" was not found.`) @@ -122,236 +112,231 @@ export async function requirePreseededWorkflow( export async function requirePreseededAgentDriveSkill( world: DifyWorld, + client: ConsoleClient, agentName: string, skillName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/drive/skills`) - await expectApiResponseOK(response, `Check preseeded Agent skill ${skillName}`) - const body = (await response.json()) as AgentDriveSkillListResponse - const skill = body.items?.find((item) => item.name === skillName) + const response = await client.agent.byAgentId.drive.skills.get({ + params: { agent_id: agent.id }, + }) + const skill = response.items?.find((item) => item.name === skillName) - if (!skill) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, - ) - } + if (!skill) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, + ) + } - return { - id: skill.path, - kind: 'skill', - name: skill.name, - } - } finally { - await ctx.dispose() + return { + id: skill.path, + kind: 'skill', + name: skill.name, } } export async function requirePreseededFullConfigAgentCoreConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const stableModel = await requireAgentBuilderStableChatModel(world) + const stableModel = await requireAgentBuilderStableChatModel(world, client) - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) await requirePreseededAgentDriveSkill( world, + client, agentName, agentBuilderPreseededResources.summarySkill, ) - const jsonTool = await requirePreseededTool(world, agentBuilderPreseededResources.jsonReplaceTool) + const jsonTool = await requirePreseededTool( + world, + client, + agentBuilderPreseededResources.jsonReplaceTool, + ) const knowledgeBase = await requireReadyPreseededDataset( world, + client, agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent core configuration ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const missing: string[] = [] - const model = asRecord(soul.model) - if (model.model_provider !== stableModel.provider || model.model !== stableModel.name) - missing.push(`${agentBuilderPreseededResources.stableChatModel} model config`) + const model = asRecord(soul.model) + if (model.model_provider !== stableModel.provider || model.model !== stableModel.name) + missing.push(`${agentBuilderPreseededResources.stableChatModel} model config`) - const prompt = asString(asRecord(soul.prompt).system_prompt) - if (!prompt.includes(agentBuilderExpectedTokens.agentReply)) - missing.push(`Prompt token ${agentBuilderExpectedTokens.agentReply}`) + const prompt = asString(asRecord(soul.prompt).system_prompt) + if (!prompt.includes(agentBuilderExpectedTokens.agentReply)) + missing.push(`Prompt token ${agentBuilderExpectedTokens.agentReply}`) - const files = asArray(soul.config_files) - for (const fileName of [ - agentBuilderTestMaterials.smallFile, - agentBuilderTestMaterials.specialFilename, - ]) { - if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) - } - - const skills = asArray(soul.config_skills) - if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) - missing.push(agentBuilderPreseededResources.summarySkill) - - const { providerName, toolName } = splitToolResourceId(jsonTool.id) - const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) - if ( - parsedTool.ok && - !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { - providerDisplayName: parsedTool.providerName, - providerName, - toolDisplayName: parsedTool.toolName, - toolName, - }) - ) { - missing.push(agentBuilderPreseededResources.jsonReplaceTool) - } - - if (!hasKnowledgeDataset(soul, knowledgeBase)) - missing.push(agentBuilderPreseededResources.agentKnowledgeBase) - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing core fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + const files = asArray(soul.config_files) + for (const fileName of [ + agentBuilderTestMaterials.smallFile, + agentBuilderTestMaterials.specialFilename, + ]) { + if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) } + + const skills = asArray(soul.config_skills) + if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) + missing.push(agentBuilderPreseededResources.summarySkill) + + const { providerName, toolName } = splitToolResourceId(jsonTool.id) + const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) + if ( + parsedTool.ok && + !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { + providerDisplayName: parsedTool.providerName, + providerName, + toolDisplayName: parsedTool.toolName, + toolName, + }) + ) { + missing.push(agentBuilderPreseededResources.jsonReplaceTool) + } + + if (!hasKnowledgeDataset(soul, knowledgeBase)) + missing.push(agentBuilderPreseededResources.agentKnowledgeBase) + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing core fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } export async function requirePreseededToolStatesAgentConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) await requirePreseededAgentDriveSkill( world, + client, agentName, agentBuilderPreseededResources.summarySkill, ) - const jsonTool = await requirePreseededTool(world, agentBuilderPreseededResources.jsonReplaceTool) + const jsonTool = await requirePreseededTool( + world, + client, + agentBuilderPreseededResources.jsonReplaceTool, + ) const tavilyTool = await requirePreseededTool( world, + client, agentBuilderPreseededResources.tavilySearchTool, ) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent tool states ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const toolItems = asArray(asRecord(soul.tools).dify_tools) - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const toolItems = asArray(asRecord(soul.tools).dify_tools) + const missing: string[] = [] - const skills = asArray(soul.config_skills) - if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) - missing.push(agentBuilderPreseededResources.summarySkill) + const skills = asArray(soul.config_skills) + if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) + missing.push(agentBuilderPreseededResources.summarySkill) - const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( - jsonTool.id, - ) - const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) - if ( - parsedJsonTool.ok && - !findToolEntry(toolItems, { - providerDisplayName: parsedJsonTool.providerName, - providerName: jsonProviderName, - toolDisplayName: parsedJsonTool.toolName, - toolName: jsonToolName, - }) - ) { - missing.push(agentBuilderPreseededResources.jsonReplaceTool) - } - - const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( - tavilyTool.id, - ) - const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) - const tavilyEntry = parsedTavilyTool.ok - ? findToolEntry(toolItems, { - providerDisplayName: parsedTavilyTool.providerName, - providerName: tavilyProviderName, - toolDisplayName: parsedTavilyTool.toolName, - toolName: tavilyToolName, - }) - : undefined - - if (!tavilyEntry) { - missing.push(agentBuilderPreseededResources.tavilySearchTool) - } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { - missing.push( - `${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`, - ) - } - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing tool state fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( + jsonTool.id, + ) + const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) + if ( + parsedJsonTool.ok && + !findToolEntry(toolItems, { + providerDisplayName: parsedJsonTool.providerName, + providerName: jsonProviderName, + toolDisplayName: parsedJsonTool.toolName, + toolName: jsonToolName, + }) + ) { + missing.push(agentBuilderPreseededResources.jsonReplaceTool) } + + const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( + tavilyTool.id, + ) + const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) + const tavilyEntry = parsedTavilyTool.ok + ? findToolEntry(toolItems, { + providerDisplayName: parsedTavilyTool.providerName, + providerName: tavilyProviderName, + toolDisplayName: parsedTavilyTool.toolName, + toolName: tavilyToolName, + }) + : undefined + + if (!tavilyEntry) { + missing.push(agentBuilderPreseededResources.tavilySearchTool) + } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { + missing.push(`${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`) + } + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing tool state fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } export async function requirePreseededDualRetrievalAgentConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) const knowledgeBase = await requireReadyPreseededDataset( world, + client, agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent dual retrieval ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const missing: string[] = [] - if (!hasKnowledgeSet(soul, knowledgeBase, { queryMode: 'generated_query' })) - missing.push('Agent decide Knowledge Retrieval') + if (!hasKnowledgeSet(soul, knowledgeBase, { queryMode: 'generated_query' })) + missing.push('Agent decide Knowledge Retrieval') - if ( - !hasKnowledgeSet(soul, knowledgeBase, { - queryMode: 'user_query', - queryValue: agentBuilderFixedInputs.customKnowledgeQuery, - }) - ) { - missing.push('Custom query Knowledge Retrieval') - } - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing dual retrieval fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + if ( + !hasKnowledgeSet(soul, knowledgeBase, { + queryMode: 'user_query', + queryValue: agentBuilderFixedInputs.customKnowledgeQuery, + }) + ) { + missing.push('Custom query Knowledge Retrieval') } + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing dual retrieval fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } diff --git a/e2e/features/agent-v2/support/fixtures/common.ts b/e2e/features/agent-v2/support/fixtures/common.ts index 802b7bf5ac3..6e30a3e0871 100644 --- a/e2e/features/agent-v2/support/fixtures/common.ts +++ b/e2e/features/agent-v2/support/fixtures/common.ts @@ -1,8 +1,4 @@ import type { DifyWorld } from '../../../support/world' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' export type PreseededResource = NonNullable< DifyWorld['agentBuilder']['fixtures']['preseededResources'][string] @@ -13,15 +9,6 @@ export type NamedResource = { name: string } -export type NamedResourceCollection = { - data: T[] -} - -export type LocalizedLabel = { - en_US?: string - zh_Hans?: string -} - export function failFixturePrerequisite( world: DifyWorld, reason: string, @@ -39,31 +26,8 @@ export function failFixturePrerequisite( throw new Error(message) } -export const findConsoleResourceByName = async ({ - action, - path, - resourceName, -}: { - action: string - path: string - resourceName: string -}) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(path) - await expectApiResponseOK(response, action) - const body = (await response.json()) as NamedResourceCollection - - return body.data.find((item) => item.name === resourceName) - } finally { - await ctx.dispose() - } -} - -export const buildQuery = (params: Record) => new URLSearchParams(params).toString() - -export const matchesNameOrLabel = (value: string, name: string, label?: LocalizedLabel) => - value === name || value === label?.en_US || value === label?.zh_Hans +export const findResourceByName = (resources: T[], resourceName: string) => + resources.find((item) => item.name === resourceName) export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -74,6 +38,16 @@ export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? va export const asString = (value: unknown) => (typeof value === 'string' ? value : '') +export const matchesNameOrLabel = (value: string, name: string, label?: unknown) => { + const localizedLabel = asRecord(label) + + return ( + value === name || + value === asString(localizedLabel.en_US) || + value === asString(localizedLabel.zh_Hans) + ) +} + export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) => items.some((item) => { const record = asRecord(item) diff --git a/e2e/features/agent-v2/support/fixtures/datasets.ts b/e2e/features/agent-v2/support/fixtures/datasets.ts index c7bc58601fb..bcfcc9369fb 100644 --- a/e2e/features/agent-v2/support/fixtures/datasets.ts +++ b/e2e/features/agent-v2/support/fixtures/datasets.ts @@ -1,21 +1,16 @@ import type { - ConsoleSegmentListResponse, DatasetListItemResponse, - DocumentStatusListResponse, DocumentWithSegmentsListResponse, } from '@dify/contracts/api/console/datasets/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../agent-builder-resources' -import { buildQuery, failFixturePrerequisite, findConsoleResourceByName } from './common' +import { failFixturePrerequisite, findResourceByName } from './common' type DocumentIndexingStatus = | 'cleaning' @@ -26,90 +21,58 @@ type DocumentIndexingStatus = | 'waiting' const completedDocumentIndexingStatus: DocumentIndexingStatus = 'completed' -export const getPreseededDataset = async (resourceName: string) => { - const query = buildQuery({ keyword: resourceName, limit: '20', page: '1' }) - - return findConsoleResourceByName({ - action: `Check preseeded dataset ${resourceName}`, - path: `/console/api/datasets?${query}`, - resourceName, - }) -} - -const getDatasetIndexingStatuses = async (datasetId: string, resourceName: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) - await expectApiResponseOK(response, `Check preseeded dataset indexing status ${resourceName}`) - const body = (await response.json()) as DocumentStatusListResponse - - return body.data - } finally { - await ctx.dispose() - } -} - -const getDatasetDocuments = async (datasetId: string, resourceName: string) => { +const getDatasetDocuments = async (client: ConsoleClient, datasetId: string) => { const documents: DocumentWithSegmentsListResponse['data'] = [] - const ctx = await createConsoleApiContext() - try { - let page = 1 - let hasMore = true + let page = 1 + let hasMore = true - while (hasMore) { - const query = buildQuery({ limit: '100', page: String(page) }) - const response = await ctx.get(`/console/api/datasets/${datasetId}/documents?${query}`) - await expectApiResponseOK(response, `List preseeded dataset documents ${resourceName}`) - const body = (await response.json()) as DocumentWithSegmentsListResponse + while (hasMore) { + const response = await client.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: { limit: '100', page: String(page) }, + }) - documents.push(...body.data) - hasMore = body.has_more - page += 1 - } - - return documents - } finally { - await ctx.dispose() + documents.push(...response.data) + hasMore = response.has_more + page += 1 } + + return documents } const datasetHasEnabledSegmentContainingTokens = async ( + client: ConsoleClient, datasetId: string, - resourceName: string, expectedTokens: string[], ) => { - const documents = await getDatasetDocuments(datasetId, resourceName) - const ctx = await createConsoleApiContext() - try { - for (const document of documents) { - const query = buildQuery({ + const documents = await getDatasetDocuments(client, datasetId) + for (const document of documents) { + const response = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { + dataset_id: datasetId, + document_id: document.id, + }, + query: { enabled: 'true', keyword: agentBuilderExpectedTokens.knowledgeReply, - limit: '20', - page: '1', - }) - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${query}`, - ) - await expectApiResponseOK(response, `Check preseeded dataset segment content ${resourceName}`) - const body = (await response.json()) as ConsoleSegmentListResponse - const matchingSegment = body.data.find( - (segment) => - segment.enabled && - expectedTokens.every( - (expectedToken) => - segment.content.includes(expectedToken) || - segment.keywords?.some((keyword) => keyword.includes(expectedToken)), - ), - ) + limit: 20, + page: 1, + }, + }) + const matchingSegment = response.data.find( + (segment) => + segment.enabled && + expectedTokens.every( + (expectedToken) => + segment.content.includes(expectedToken) || + segment.keywords?.some((keyword) => keyword.includes(expectedToken)), + ), + ) - if (matchingSegment) return true - } - - return false - } finally { - await ctx.dispose() + if (matchingSegment) return true } + + return false } const toDatasetResource = (resource: DatasetListItemResponse): PreseededResource => ({ @@ -120,9 +83,13 @@ const toDatasetResource = (resource: DatasetListItemResponse): PreseededResource export async function requireReadyPreseededDataset( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const resource = await getPreseededDataset(resourceName) + const response = await client.datasets.get({ + query: { keyword: resourceName, limit: 20, page: 1 }, + }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded dataset "${resourceName}" was not found.`) @@ -138,7 +105,10 @@ export async function requireReadyPreseededDataset( ) } - const statuses = await getDatasetIndexingStatuses(resource.id, resourceName) + const indexingStatus = await client.datasets.byDatasetId.indexingStatus.get({ + params: { dataset_id: resource.id }, + }) + const statuses = indexingStatus.data if (statuses.length < 1) { return failFixturePrerequisite( world, @@ -163,8 +133,8 @@ export async function requireReadyPreseededDataset( agentBuilderExpectedTokens.knowledgeReply, ] const hasExpectedToken = await datasetHasEnabledSegmentContainingTokens( + client, resource.id, - resourceName, requiredTokens, ) diff --git a/e2e/features/agent-v2/support/fixtures/models.ts b/e2e/features/agent-v2/support/fixtures/models.ts index d1acc1f2ec2..b00d1448a95 100644 --- a/e2e/features/agent-v2/support/fixtures/models.ts +++ b/e2e/features/agent-v2/support/fixtures/models.ts @@ -1,12 +1,5 @@ -import type { - DefaultModelDataResponse, - ProviderWithModelsResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { agentBuilderPreseededResources } from '../agent-builder-resources' import { failFixturePrerequisite } from './common' @@ -76,6 +69,7 @@ export function readAgentBuilderAgentDecisionChatModelConfig(): ModelFixtureConf async function requireAgentBuilderModel( world: DifyWorld, + client: ConsoleClient, config: ModelFixtureConfig, { requireActive, @@ -85,83 +79,70 @@ async function requireAgentBuilderModel( ): Promise> { if (!config.ok) return failFixturePrerequisite(world, config.reason) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/models/model-types/${config.type}`, + const response = await client.workspaces.current.models.modelTypes.byModelType.get({ + params: { model_type: config.type }, + }) + const provider = response.data.find((item) => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + (item) => + item.model === config.value || + item.label?.en_US === config.value || + item.label?.zh_Hans === config.value, + ) + + if (!provider || !model) { + return failFixturePrerequisite( + world, + `${config.resourceName} was not found as ${config.provider}/${config.value} (${config.type}).`, ) - await expectApiResponseOK(response, `Check ${config.resourceName}`) - const body = (await response.json()) as { data: ProviderWithModelsResponse[] } - const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) - const model = provider?.models.find( - (item) => - item.model === config.value || - item.label?.en_US === config.value || - item.label?.zh_Hans === config.value, + } + + if (requireActive && model.status !== activeModelStatus) { + return failFixturePrerequisite( + world, + `${config.resourceName} is ${model.status ?? 'missing status'} instead of ${activeModelStatus}.`, ) + } - if (!provider || !model) { - return failFixturePrerequisite( - world, - `${config.resourceName} was not found as ${config.provider}/${config.value} (${config.type}).`, - ) - } - - if (requireActive && model.status !== activeModelStatus) { - return failFixturePrerequisite( - world, - `${config.resourceName} is ${model.status ?? 'missing status'} instead of ${activeModelStatus}.`, - ) - } - - return { - name: model.model, - provider: provider.provider, - type: config.type, - } - } finally { - await ctx.dispose() + return { + name: model.model, + provider: provider.provider, + type: config.type, } } export async function requireAgentBuilderStableChatModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - return requireAgentBuilderModel(world, readAgentBuilderStableChatModelConfig(), { + return requireAgentBuilderModel(world, client, readAgentBuilderStableChatModelConfig(), { requireActive: true, }) } export async function requireAgentBuilderSpeechToTextModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - const ctx = await createConsoleApiContext() - let defaultModel: NonNullable - - try { - const response = await ctx.get( - '/console/api/workspaces/current/default-model?model_type=speech2text', + const response = await client.workspaces.current.defaultModel.get({ + query: { model_type: 'speech2text' }, + }) + if (!response.data) { + return failFixturePrerequisite( + world, + `${agentBuilderPreseededResources.speechToTextModel} is not configured.`, + { + owner: 'model-provider/seed', + remediation: + 'Configure an active workspace default Speech-to-Text model before running the external scenario.', + }, ) - await expectApiResponseOK(response, `Check ${agentBuilderPreseededResources.speechToTextModel}`) - const body = (await response.json()) as DefaultModelDataResponse - if (!body.data) { - return failFixturePrerequisite( - world, - `${agentBuilderPreseededResources.speechToTextModel} is not configured.`, - { - owner: 'model-provider/seed', - remediation: - 'Configure an active workspace default Speech-to-Text model before running the external scenario.', - }, - ) - } - defaultModel = body.data - } finally { - await ctx.dispose() } + const defaultModel = response.data return requireAgentBuilderModel( world, + client, { ok: true, provider: defaultModel.provider.provider, @@ -177,8 +158,9 @@ export async function requireAgentBuilderSpeechToTextModel( export async function requireAgentBuilderAgentDecisionChatModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - return requireAgentBuilderModel(world, readAgentBuilderAgentDecisionChatModelConfig(), { + return requireAgentBuilderModel(world, client, readAgentBuilderAgentDecisionChatModelConfig(), { requireActive: true, }) } diff --git a/e2e/features/agent-v2/support/fixtures/tools.ts b/e2e/features/agent-v2/support/fixtures/tools.ts index b8be21c04ec..b380848920b 100644 --- a/e2e/features/agent-v2/support/fixtures/tools.ts +++ b/e2e/features/agent-v2/support/fixtures/tools.ts @@ -1,20 +1,8 @@ +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' -import type { LocalizedLabel, PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' +import type { PreseededResource } from './common' import { asRecord, asString, failFixturePrerequisite, matchesNameOrLabel } from './common' -type BuiltinToolProvider = { - label?: LocalizedLabel - name: string - tools: Array<{ - label?: LocalizedLabel - name: string - }> -} - export const splitToolDisplayName = (resourceName: string) => { const [providerName, toolName] = resourceName.split('/').map((item) => item.trim()) @@ -91,32 +79,26 @@ export const hasUnauthorizedToolCredentialState = (item: unknown) => { export async function requirePreseededTool( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { const parsed = splitToolDisplayName(resourceName) if (!parsed.ok) return failFixturePrerequisite(world, parsed.reason) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get('/console/api/workspaces/current/tools/builtin') - await expectApiResponseOK(response, `Check preseeded tool ${resourceName}`) - const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find((item) => - matchesNameOrLabel(parsed.providerName, item.name, item.label), - ) - const tool = provider?.tools.find((item) => - matchesNameOrLabel(parsed.toolName, item.name, item.label), - ) + const providers = await client.workspaces.current.tools.builtin.get() + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools?.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) - if (!provider || !tool) - return failFixturePrerequisite(world, `Preseeded tool "${resourceName}" was not found.`) + if (!provider || !tool) + return failFixturePrerequisite(world, `Preseeded tool "${resourceName}" was not found.`) - return { - id: `${provider.name}/${tool.name}`, - kind: 'tool', - name: resourceName, - } - } finally { - await ctx.dispose() + return { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: resourceName, } } diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index 08c592e8166..fd0ec8f8728 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -3,28 +3,15 @@ import type { AgentSoulConfig, AgentSoulDifyToolConfig, } from '@dify/contracts/api/console/agent/types.gen' -import type { - ConsoleSegmentListResponse, - DatasetListItemResponse, - DocumentStatusListResponse, - DocumentWithSegmentsListResponse, - KnowledgeConfig, -} from '@dify/contracts/api/console/datasets/types.gen' -import type { - AvailableModelListResponse, - DefaultModelDataResponse, - ModelProviderListResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' +import type { KnowledgeConfig } from '@dify/contracts/api/console/datasets/types.gen' +import type { ModelType } from '@dify/contracts/api/console/workspaces/types.gen' import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed' -import type { UploadedConsoleFile } from './agent-drive' import { readFile } from 'node:fs/promises' import { createTestApp } from '../../../support/api/apps' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' -import { publishWorkflowApp } from '../../../support/api/workflows' import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins' import { sleep } from '../../../support/process' import { blocked, created, skipped, updated, verified } from '../../../support/seed' -import { createTestAgent, publishAgent, saveAgentComposerDraft } from './agent' +import { createTestAgent, saveAgentComposerDraft } from './agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -41,12 +28,7 @@ import { createAgentSoulConfigWithModel, normalAgentSoulConfig, } from './agent-soul' -import { - buildQuery, - findConsoleResourceByName, - isRecord, - matchesNameOrLabel, -} from './fixtures/common' +import { isRecord, matchesNameOrLabel } from './fixtures/common' import { splitToolDisplayName } from './fixtures/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials' import { syncAgentV2WorkflowDraft } from './workflow' @@ -54,7 +36,7 @@ import { syncAgentV2WorkflowDraft } from './workflow' type StableModel = { name: string provider: string - type: string + type: ModelType } type ToolResource = SeedResource & { @@ -88,16 +70,33 @@ const matchesProviderLabel = ( provider.label?.en_US === expected || provider.label?.zh_Hans === expected +const parseModelType = (value: string | undefined, fallback: ModelType): ModelType => { + const modelType = value?.trim() + if (!modelType) return fallback + + switch (modelType) { + case 'llm': + case 'moderation': + case 'rerank': + case 'speech2text': + case 'text-embedding': + case 'tts': + return modelType + default: + throw new Error(`Unsupported model type "${modelType}".`) + } +} + const stableModelConfig = (): StableModel => ({ name: process.env.E2E_STABLE_MODEL_NAME?.trim() || 'gpt-5-nano', provider: process.env.E2E_STABLE_MODEL_PROVIDER?.trim() || 'openai', - type: process.env.E2E_STABLE_MODEL_TYPE?.trim() || 'llm', + type: parseModelType(process.env.E2E_STABLE_MODEL_TYPE, 'llm'), }) const agentDecisionModelConfig = (): StableModel => ({ name: process.env.E2E_AGENT_DECISION_MODEL_NAME?.trim() || 'gpt-5.5', provider: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER?.trim() || 'openai', - type: process.env.E2E_AGENT_DECISION_MODEL_TYPE?.trim() || 'llm', + type: parseModelType(process.env.E2E_AGENT_DECISION_MODEL_TYPE, 'llm'), }) const speechToTextModelConfig = (): StableModel => ({ @@ -123,120 +122,86 @@ const parseJsonEnv = (envName: string) => { } } -const findModel = async (config: StableModel, title: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/models/model-types/${config.type}`, - ) - await expectApiResponseOK(response, `Check ${title}`) - const body = (await response.json()) as AvailableModelListResponse - const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) - const model = provider?.models.find( - (item) => - item.model === config.name || - item.label?.en_US === config.name || - item.label?.zh_Hans === config.name, - ) +const findModel = async (client: SeedContext['consoleClient'], config: StableModel) => { + const body = await client.workspaces.current.models.modelTypes.byModelType.get({ + params: { model_type: config.type }, + }) + const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + (item) => + item.model === config.name || + item.label?.en_US === config.name || + item.label?.zh_Hans === config.name, + ) - if (!provider || !model) return undefined + if (!provider || !model) return undefined - return { - name: model.model, - provider: provider.provider, - status: model.status, - type: config.type, - } - } finally { - await ctx.dispose() + return { + name: model.model, + provider: provider.provider, + status: model.status, + type: config.type, } } -const resolveProvider = async (config: StableModel) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`, - ) - await expectApiResponseOK(response, `Resolve model provider ${config.provider}`) - const body = (await response.json()) as ModelProviderListResponse - const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) +const resolveProvider = async (client: SeedContext['consoleClient'], config: StableModel) => { + const body = await client.workspaces.current.modelProviders.get({ + query: { model_type: config.type }, + }) + const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) - return { - availableProviders: body.data.map((provider) => provider.provider), - credential: provider?.custom_configuration.available_credentials?.find( - (credential) => credential.credential_name === stableModelCredentialName, - ), - provider: provider?.provider, - } - } finally { - await ctx.dispose() + return { + availableProviders: body.data.map((provider) => provider.provider), + credential: provider?.custom_configuration.available_credentials?.find( + (credential) => credential.credential_name === stableModelCredentialName, + ), + provider: provider?.provider, } } -const selectCustomProviderCredential = async (provider: string, credentialId?: string) => { - const ctx = await createConsoleApiContext() - try { - if (credentialId) { - const switchResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/credentials/switch`, - { - data: { credential_id: credentialId }, - }, - ) - await expectApiResponseOK(switchResponse, `Switch model provider credential for ${provider}`) - } - - const preferredResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/preferred-provider-type`, - { - data: { preferred_provider_type: 'custom' }, - }, - ) - await expectApiResponseOK( - preferredResponse, - `Select custom provider credential for ${provider}`, - ) - } finally { - await ctx.dispose() +const selectCustomProviderCredential = async ( + client: SeedContext['consoleClient'], + provider: string, + credentialId?: string, +) => { + if (credentialId) { + await client.workspaces.current.modelProviders.byProvider.credentials.switch.post({ + body: { credential_id: credentialId }, + params: { provider }, + }) } + + await client.workspaces.current.modelProviders.byProvider.preferredProviderType.post({ + body: { preferred_provider_type: 'custom' }, + params: { provider }, + }) } const upsertStableProviderCredential = async ( + client: SeedContext['consoleClient'], provider: string, credentials: Record, credentialId?: string, ) => { - const ctx = await createConsoleApiContext() - try { - if (credentialId) { - const updateResponse = await ctx.put( - `/console/api/workspaces/current/model-providers/${provider}/credentials`, - { - data: { - credential_id: credentialId, - credentials, - name: stableModelCredentialName, - }, - }, - ) - await expectApiResponseOK(updateResponse, `Update model provider credential for ${provider}`) - return - } - - const createResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/credentials`, - { - data: { - credentials, - name: stableModelCredentialName, - }, + if (credentialId) { + await client.workspaces.current.modelProviders.byProvider.credentials.put({ + body: { + credential_id: credentialId, + credentials, + name: stableModelCredentialName, }, - ) - await expectApiResponseOK(createResponse, `Create model provider credential for ${provider}`) - } finally { - await ctx.dispose() + params: { provider }, + }) + return } + + await client.workspaces.current.modelProviders.byProvider.credentials.post({ + body: { + credentials, + name: stableModelCredentialName, + }, + params: { provider }, + }) } const seedModel = async ( @@ -249,7 +214,7 @@ const seedModel = async ( title: string }, ) => { - const existing = await findModel(config, title) + const existing = await findModel(context.consoleClient, config) const resource = { id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`, kind: 'model', @@ -268,7 +233,10 @@ const seedModel = async ( if (!credentials.ok) return blocked(title, `${config.provider}/${config.name} is not active; ${credentials.reason}`) - const { availableProviders, credential, provider } = await resolveProvider(config) + const { availableProviders, credential, provider } = await resolveProvider( + context.consoleClient, + config, + ) if (!provider) { const available = availableProviders.length > 0 ? availableProviders.join(', ') : 'none' return blocked( @@ -278,14 +246,19 @@ const seedModel = async ( } try { - await upsertStableProviderCredential(provider, credentials.value, credential?.credential_id) - await selectCustomProviderCredential(provider, credential?.credential_id) + await upsertStableProviderCredential( + context.consoleClient, + provider, + credentials.value, + credential?.credential_id, + ) + await selectCustomProviderCredential(context.consoleClient, provider, credential?.credential_id) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (!message.includes(`Credential with name '${stableModelCredentialName}' already exists.`)) return blocked(title, message) - const refreshed = await resolveProvider(config) + const refreshed = await resolveProvider(context.consoleClient, config) if (!refreshed.provider || !refreshed.credential) { return blocked( title, @@ -295,17 +268,22 @@ const seedModel = async ( try { await upsertStableProviderCredential( + context.consoleClient, refreshed.provider, credentials.value, refreshed.credential.credential_id, ) - await selectCustomProviderCredential(refreshed.provider, refreshed.credential.credential_id) + await selectCustomProviderCredential( + context.consoleClient, + refreshed.provider, + refreshed.credential.credential_id, + ) } catch (retryError) { return blocked(title, retryError instanceof Error ? retryError.message : String(retryError)) } } - const seeded = await findModel(config, title) + const seeded = await findModel(context.consoleClient, config) if (seeded?.status !== activeModelStatus) { return blocked( title, @@ -332,47 +310,13 @@ const seedAgentDecisionModel = async (context: SeedContext) => title: agentBuilderPreseededResources.agentDecisionChatModel, }) -const getDefaultModel = async (modelType: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/default-model?${buildQuery({ model_type: modelType })}`, - ) - await expectApiResponseOK(response, `Get default ${modelType} model`) - const body = (await response.json()) as DefaultModelDataResponse - return body.data - } finally { - await ctx.dispose() - } -} - -const setDefaultModel = async (model: StableModel) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/default-model', { - data: { - model_settings: [ - { - model: model.name, - model_type: model.type, - provider: model.provider, - }, - ], - }, - }) - await expectApiResponseOK(response, `Set default ${model.type} model`) - } finally { - await ctx.dispose() - } -} - const seedSpeechToTextModel = async (context: SeedContext) => { const config = speechToTextModelConfig() const title = agentBuilderPreseededResources.speechToTextModel const modelResult = await seedModel(context, { config, title }) if (modelResult.status === 'blocked' || modelResult.status === 'skipped') return modelResult - const model = await findModel(config, title) + const model = await findModel(context.consoleClient, config) if (!model || model.status !== activeModelStatus) return blocked(title, `${config.provider}/${config.name} is not active after model setup.`) @@ -381,7 +325,10 @@ const seedSpeechToTextModel = async (context: SeedContext) => { kind: 'model', name: title, } - const defaultModel = await getDefaultModel(config.type) + const defaultModelResponse = await context.consoleClient.workspaces.current.defaultModel.get({ + query: { model_type: config.type }, + }) + const defaultModel = defaultModelResponse.data const isExpectedDefault = defaultModel?.model === model.name && matchesProvider(defaultModel.provider.provider, model.provider) @@ -395,13 +342,23 @@ const seedSpeechToTextModel = async (context: SeedContext) => { `Would set ${model.provider}/${model.name} as the workspace default Speech-to-Text model.`, ) - await setDefaultModel({ - name: model.name, - provider: model.provider, - type: config.type, + await context.consoleClient.workspaces.current.defaultModel.post({ + body: { + model_settings: [ + { + model: model.name, + model_type: config.type, + provider: model.provider, + }, + ], + }, }) - const updatedDefaultModel = await getDefaultModel(config.type) + const updatedDefaultModelResponse = + await context.consoleClient.workspaces.current.defaultModel.get({ + query: { model_type: config.type }, + }) + const updatedDefaultModel = updatedDefaultModelResponse.data if ( updatedDefaultModel?.model !== model.name || !matchesProvider(updatedDefaultModel.provider.provider, model.provider) @@ -415,103 +372,48 @@ const seedSpeechToTextModel = async (context: SeedContext) => { return updated(title, resource) } -type BuiltinToolProvider = { - label?: { en_US?: string; zh_Hans?: string } - name: string - tools: Array<{ - label?: { en_US?: string; zh_Hans?: string } - name: string - }> -} - -const findBuiltinTool = async (displayName: string) => { +const findBuiltinTool = async (client: SeedContext['consoleClient'], displayName: string) => { const parsed = splitToolDisplayName(displayName) if (!parsed.ok) return { ok: false as const, reason: parsed.reason } - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get('/console/api/workspaces/current/tools/builtin') - await expectApiResponseOK(response, `Check built-in tool ${displayName}`) - const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find((item) => - matchesNameOrLabel(parsed.providerName, item.name, item.label), - ) - const tool = provider?.tools.find((item) => - matchesNameOrLabel(parsed.toolName, item.name, item.label), - ) + const providers = await client.workspaces.current.tools.builtin.get() + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools?.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) - if (!provider || !tool) - return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } + if (!provider || !tool) + return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } - return { - ok: true as const, - resource: { - id: `${provider.name}/${tool.name}`, - kind: 'tool', - name: displayName, - providerName: provider.name, - toolName: tool.name, - } satisfies ToolResource, - } - } finally { - await ctx.dispose() + return { + ok: true as const, + resource: { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: displayName, + providerName: provider.name, + toolName: tool.name, + } satisfies ToolResource, } } const seedTool = (displayName: string): SeedTask => ({ id: `tool:${displayName}`, title: displayName, - async run() { - const result = await findBuiltinTool(displayName) + async run(context) { + const result = await findBuiltinTool(context.consoleClient, displayName) if (!result.ok) return blocked(displayName, result.reason) return verified(displayName, result.resource) }, }) -const uploadConsoleFile = async ( - fileName: string, - filePath: string, -): Promise => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/files/upload', { - multipart: { - file: { - buffer: await readFile(filePath), - mimeType: 'text/plain', - name: fileName, - }, - }, - }) - await expectApiResponseOK(response, `Upload seed file ${fileName}`) - return (await response.json()) as UploadedConsoleFile - } finally { - await ctx.dispose() - } -} - -const findDataset = (name: string) => { - const query = buildQuery({ keyword: name, limit: '20', page: '1' }) - return findConsoleResourceByName({ - action: `Find seed dataset ${name}`, - path: `/console/api/datasets?${query}`, - resourceName: name, - }) -} - -const getDatasetDocuments = async (datasetId: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`, - ) - await expectApiResponseOK(response, `List dataset documents ${datasetId}`) - const body = (await response.json()) as DocumentWithSegmentsListResponse - return body.data - } finally { - await ctx.dispose() - } +const findDataset = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.datasets.get({ query: { keyword: name, limit: 20, page: 1 } }) + const dataset = body.data.find((dataset) => dataset.name === name) + return dataset ? { id: dataset.id, name: dataset.name } : undefined } const requiredKnowledgeSegmentTokens = [ @@ -520,62 +422,54 @@ const requiredKnowledgeSegmentTokens = [ agentBuilderExpectedTokens.knowledgeReply, ] -const datasetHasKnowledgeSegment = async (datasetId: string) => { - const documents = await getDatasetDocuments(datasetId) - const ctx = await createConsoleApiContext() - try { - for (const document of documents) { - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${buildQuery({ - enabled: 'true', - keyword: agentBuilderExpectedTokens.knowledgeReply, - limit: '20', - page: '1', - })}`, +const datasetHasKnowledgeSegment = async ( + client: SeedContext['consoleClient'], + datasetId: string, +) => { + const documents = await client.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: { limit: '100', page: '1' }, + }) + for (const document of documents.data) { + const body = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { dataset_id: datasetId, document_id: document.id }, + query: { + enabled: 'true', + keyword: agentBuilderExpectedTokens.knowledgeReply, + limit: 20, + page: 1, + }, + }) + if ( + body.data.some( + (segment) => + segment.enabled && + requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), ) - await expectApiResponseOK( - response, - `Check dataset knowledge segment ${agentBuilderExpectedTokens.knowledgeReply}`, - ) - const body = (await response.json()) as ConsoleSegmentListResponse - if ( - body.data.some( - (segment) => - segment.enabled && - requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), - ) - ) { - return true - } + ) { + return true } - - return false - } finally { - await ctx.dispose() } + + return false } -const waitForDatasetCompleted = async (datasetId: string) => { +const waitForDatasetCompleted = async (client: SeedContext['consoleClient'], datasetId: string) => { const deadline = Date.now() + 180_000 let status = 'missing' while (Date.now() < deadline) { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) - await expectApiResponseOK(response, `Check dataset indexing ${datasetId}`) - const body = (await response.json()) as DocumentStatusListResponse - status = - body.data.length < 1 - ? 'missing' - : body.data.every((item) => item.indexing_status === 'completed') - ? 'completed' - : body.data.map((item) => item.indexing_status ?? 'missing').join(',') + const body = await client.datasets.byDatasetId.indexingStatus.get({ + params: { dataset_id: datasetId }, + }) + status = + body.data.length < 1 + ? 'missing' + : body.data.every((item) => item.indexing_status === 'completed') + ? 'completed' + : body.data.map((item) => item.indexing_status ?? 'missing').join(',') - if (status === 'completed') return { ok: true as const } - } finally { - await ctx.dispose() - } + if (status === 'completed') return { ok: true as const } await sleep(1_000) } @@ -583,11 +477,17 @@ const waitForDatasetCompleted = async (datasetId: string) => { return { ok: false as const, status } } -const addKnowledgeDocument = async (datasetId: string) => { - const uploadedFile = await uploadConsoleFile( - agentBuilderTestMaterials.knowledgeSource, - getAgentBuilderTestMaterialPath('knowledgeSource'), - ) +const addKnowledgeDocument = async (client: SeedContext['consoleClient'], datasetId: string) => { + const fileName = agentBuilderTestMaterials.knowledgeSource + const uploadedFile = await client.files.upload.post({ + body: { + file: new File( + [Uint8Array.from(await readFile(getAgentBuilderTestMaterialPath('knowledgeSource')))], + fileName, + { type: 'text/plain' }, + ), + }, + }) const body = { data_source: { info_list: { @@ -611,36 +511,15 @@ const addKnowledgeDocument = async (datasetId: string) => { }, } satisfies KnowledgeConfig - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/datasets/${datasetId}/documents`, { data: body }) - await expectApiResponseOK(response, `Seed knowledge document ${datasetId}`) - } finally { - await ctx.dispose() - } -} - -const createDataset = async (name: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/datasets', { - data: { - indexing_technique: 'economy', - name, - permission: 'only_me', - provider: 'vendor', - }, - }) - await expectApiResponseOK(response, `Create dataset ${name}`) - return (await response.json()) as DatasetListItemResponse - } finally { - await ctx.dispose() - } + await client.datasets.byDatasetId.documents.post({ + body, + params: { dataset_id: datasetId }, + }) } const seedReadyKnowledge = async (context: SeedContext) => { const title = agentBuilderPreseededResources.agentKnowledgeBase - let dataset = await findDataset(title) + let dataset = await findDataset(context.consoleClient, title) if (context.dryRun) { return dataset @@ -649,12 +528,22 @@ const seedReadyKnowledge = async (context: SeedContext) => { } const wasCreated = !dataset - dataset ??= await createDataset(title) + if (!dataset) { + const createdDataset = await context.consoleClient.datasets.post({ + body: { + indexing_technique: 'economy', + name: title, + permission: 'only_me', + provider: 'vendor', + }, + }) + dataset = { id: createdDataset.id, name: createdDataset.name } + } - const hasKnowledgeSegment = await datasetHasKnowledgeSegment(dataset.id) - if (!hasKnowledgeSegment) await addKnowledgeDocument(dataset.id) + const hasKnowledgeSegment = await datasetHasKnowledgeSegment(context.consoleClient, dataset.id) + if (!hasKnowledgeSegment) await addKnowledgeDocument(context.consoleClient, dataset.id) - const indexing = await waitForDatasetCompleted(dataset.id) + const indexing = await waitForDatasetCompleted(context.consoleClient, dataset.id) if (!indexing.ok) { return blocked( title, @@ -662,7 +551,7 @@ const seedReadyKnowledge = async (context: SeedContext) => { ) } - return datasetHasKnowledgeSegment(dataset.id).then((ready) => { + return datasetHasKnowledgeSegment(context.consoleClient, dataset.id).then((ready) => { if (!ready) { return blocked( title, @@ -675,17 +564,13 @@ const seedReadyKnowledge = async (context: SeedContext) => { }) } -const ensureAgent = async (name: string) => { - const query = buildQuery({ limit: '20', name, page: '1' }) - const existing = await findConsoleResourceByName({ - action: `Find seed Agent ${name}`, - path: `/console/api/agent?${query}`, - resourceName: name, - }) +const ensureAgent = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.agent.get({ query: { limit: 20, name, page: 1 } }) + const existing = body.data.find((agent) => agent.name === name) if (existing) return { agent: existing, created: false } - const agent = await createTestAgent({ + const agent = await createTestAgent(client, { description: 'Created by Dify E2E seed.', name, role: 'E2E seeded assistant', @@ -721,24 +606,32 @@ const toolConfig = (tool: ToolResource) => tool_name: tool.toolName, }) satisfies AgentSoulDifyToolConfig -const saveSeededAgentComposer = async ({ - agentId, - config, - shouldPublish = false, -}: { - agentId: string - config: AgentSoulConfig - shouldPublish?: boolean -}) => { - await saveAgentComposerDraft(agentId, config) - if (shouldPublish) await publishAgent(agentId, 'E2E seed') +const saveSeededAgentComposer = async ( + client: SeedContext['consoleClient'], + { + agentId, + config, + shouldPublish = false, + }: { + agentId: string + config: AgentSoulConfig + shouldPublish?: boolean + }, +) => { + await saveAgentComposerDraft(client, agentId, config) + if (shouldPublish) { + await client.agent.byAgentId.publish.post({ + body: { version_note: 'E2E seed' }, + params: { agent_id: agentId }, + }) + } } -const ensureDriveSkill = async (agentId: string) => { - const skills = await getAgentDriveSkills(agentId) +const ensureDriveSkill = async (client: SeedContext['consoleClient'], agentId: string) => { + const skills = await getAgentDriveSkills(client, agentId) if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return - await uploadAgentDriveSkill({ + await uploadAgentDriveSkill(client, { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -759,26 +652,26 @@ const seedFullConfigAgent = async (context: SeedContext) => { if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) const agentId = agent.id - const smallFile = await uploadAgentConfigFileToDraft({ + const smallFile = await uploadAgentConfigFileToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.smallFile, filePath: getAgentBuilderTestMaterialPath('smallFile'), }) - const specialFile = await uploadAgentConfigFileToDraft({ + const specialFile = await uploadAgentConfigFileToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.specialFilename, filePath: getAgentBuilderTestMaterialPath('specialFilename'), }) - const summarySkill = await uploadAgentConfigSkillToDraft({ + const summarySkill = await uploadAgentConfigSkillToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(agentId) + await ensureDriveSkill(context.consoleClient, agentId) - await saveSeededAgentComposer({ + await saveSeededAgentComposer(context.consoleClient, { agentId, config: createAgentSoulConfigWithKnowledgeDataset( createAgentSoulConfigWithModel( @@ -813,14 +706,14 @@ const seedToolStatesAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.tavilySearchTool} is not ready.`) if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) - const summarySkill = await uploadAgentConfigSkillToDraft({ + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) + const summarySkill = await uploadAgentConfigSkillToDraft(context.consoleClient, { agentId: agent.id, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(agent.id) - await saveSeededAgentComposer({ + await ensureDriveSkill(context.consoleClient, agent.id) + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { ...normalAgentSoulConfig, @@ -842,9 +735,9 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) const datasetConfig = { id: dataset.id, name: dataset.name } satisfies AgentKnowledgeDatasetConfig - await saveSeededAgentComposer({ + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { ...normalAgentSoulConfig, @@ -876,13 +769,12 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { return wasCreated ? created(title, resource) : updated(title, resource) } -const findWorkflow = (name: string) => { - const query = buildQuery({ limit: '20', mode: 'workflow', name, page: '1' }) - return findConsoleResourceByName({ - action: `Find seed workflow ${name}`, - path: `/console/api/apps?${query}`, - resourceName: name, +const findWorkflow = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.apps.get({ + query: { limit: 20, mode: 'workflow', name, page: 1 }, }) + const workflow = body.data.find((workflow) => workflow.name === name) + return workflow ? { id: workflow.id, name: workflow.name } : undefined } const seedWorkflowReference = async (context: SeedContext) => { @@ -894,21 +786,25 @@ const seedWorkflowReference = async (context: SeedContext) => { if (context.dryRun) return skipped(title, `Would create or update Agent "${title}" and workflow "${workflowName}".`) - const { agent, created: wasAgentCreated } = await ensureAgent(title) - await saveSeededAgentComposer({ + const { agent, created: wasAgentCreated } = await ensureAgent(context.consoleClient, title) + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: createAgentSoulConfigWithModel(normalAgentSoulConfig, model), shouldPublish: true, }) - let workflow = await findWorkflow(workflowName) + let workflow = await findWorkflow(context.consoleClient, workflowName) let wasWorkflowCreated = false if (!workflow) { - workflow = await createTestApp(workflowName, 'workflow') + const createdWorkflow = await createTestApp(context.consoleClient, workflowName, 'workflow') + workflow = { id: createdWorkflow.id, name: createdWorkflow.name } wasWorkflowCreated = true } - await syncAgentV2WorkflowDraft(workflow.id, agent.id) - await publishWorkflowApp(workflow.id) + await syncAgentV2WorkflowDraft(context.consoleClient, workflow.id, agent.id) + await context.consoleClient.apps.byAppId.workflows.publish.post({ + body: {}, + params: { app_id: workflow.id }, + }) const resource = { id: workflow.id, kind: 'workflow', name: workflowName } return wasAgentCreated || wasWorkflowCreated diff --git a/e2e/features/agent-v2/support/workflow.ts b/e2e/features/agent-v2/support/workflow.ts index 54605223b05..19ca155f6d8 100644 --- a/e2e/features/agent-v2/support/workflow.ts +++ b/e2e/features/agent-v2/support/workflow.ts @@ -1,8 +1,6 @@ import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' -import { zPostAppsByAppIdWorkflowsDraftResponse } from '@dify/contracts/api/console/apps/zod.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import * as z from 'zod' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' -import { getWorkflowDraft } from '../../../support/api/workflows' const agentV2WorkflowNodeId = 'agent-v2' const zWorkflowGraph = z.object({ @@ -14,8 +12,8 @@ const zWorkflowGraph = z.object({ ), }) -export async function getAgentV2WorkflowNodeData(appId: string) { - const draft = await getWorkflowDraft(appId) +export async function getAgentV2WorkflowNodeData(client: ConsoleClient, appId: string) { + const draft = await client.apps.byAppId.workflows.draft.get({ params: { app_id: appId } }) const graph = zWorkflowGraph.parse(draft.graph) const agentNode = graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) if (!agentNode) @@ -26,47 +24,44 @@ export async function getAgentV2WorkflowNodeData(appId: string) { return agentNode.data ?? {} } -export async function syncAgentV2WorkflowDraft(appId: string, agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { +export async function syncAgentV2WorkflowDraft( + client: ConsoleClient, + appId: string, + agentId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'agent-v2', + type: 'custom', + position: { x: 420, y: 282 }, + data: { id: 'agent-v2', - type: 'custom', - position: { x: 420, y: 282 }, - data: { - id: 'agent-v2', - type: 'agent', - title: 'Agent', - desc: '', - agent_binding: { - binding_type: 'roster_agent', - agent_id: agentId, - }, - agent_node_kind: 'dify_agent', - version: '2', + type: 'agent', + title: 'Agent', + desc: '', + agent_binding: { + binding_type: 'roster_agent', + agent_id: agentId, }, + agent_node_kind: 'dify_agent', + version: '2', }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - } satisfies SyncDraftWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) - await expectApiResponseOK(response, `Sync Agent v2 workflow draft for ${appId}`) - zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) } diff --git a/e2e/features/agent-v2/tools.feature b/e2e/features/agent-v2/tools.feature index c4d3d286ebe..262389b8f69 100644 --- a/e2e/features/agent-v2/tools.feature +++ b/e2e/features/agent-v2/tools.feature @@ -26,13 +26,3 @@ Feature: Agent v2 tools Then the Agent v2 draft should be published and up to date When I send the Agent v2 Backend service API JSON Replace request Then the Agent v2 Backend service API response should include the JSON Replace E2E marker - - @core - Scenario: Tool selector recovers after an unavailable installed-tool search - Given I am signed in as the default E2E admin - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - And I search for the missing Agent v2 tool from the Tools selector - Then I should see the unavailable Agent v2 installed-tool search applied - When I clear the Agent v2 tool selector search - Then I should see the Agent v2 tool selector ready for another search diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index 32a58440429..2c9999e9e11 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -1,11 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createAgentApiKey, - sendAgentServiceApiChatMessage, - setAgentApiAccess, -} from '../../agent-v2/support/access-point' +import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -15,8 +11,12 @@ import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' async function enableAgentApiAccessWithKey(world: DifyWorld) { const agentId = getCurrentAgentId(world) - const apiAccess = await setAgentApiAccess(agentId, true) - const apiKey = await createAgentApiKey(agentId) + const client = world.getConsoleClient() + const apiAccess = await client.agent.byAgentId.apiEnable.post({ + body: { enable_api: true }, + params: { agent_id: agentId }, + }) + const apiKey = await client.agent.byAgentId.apiKeys.post({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url world.agentBuilder.accessPoint.generatedApiKey = apiKey.token diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index dabe1269416..9182bc7ad5a 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -2,7 +2,6 @@ import type { Page } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources' import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers' @@ -11,7 +10,10 @@ const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000 const getWebAppMessageInput = (webAppPage: Page) => webAppPage.getByPlaceholder(/^Talk to /).last() const recordComposerDraftSnapshot = async (world: DifyWorld) => { - const draft = await getAgentComposerDraft(getCurrentAgentId(world)) + const agentId = getCurrentAgentId(world) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) } @@ -170,7 +172,10 @@ Then( const snapshot = this.agentBuilder.accessPoint.composerDraftSnapshot if (!snapshot) throw new Error('No Agent v2 orchestration draft snapshot was recorded.') - const draft = await getAgentComposerDraft(getCurrentAgentId(this)) + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) expect(JSON.stringify(draft.agent_soul ?? {})).toBe(snapshot) }, diff --git a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts index 5fdb3d72577..472bad694d2 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts @@ -14,7 +14,7 @@ Then( agentBuilderPreseededResources.workflowReferenceAgent, 'agent', ) - const references = await getAgentReferencingWorkflows(agent.id) + const references = await getAgentReferencingWorkflows(this.getConsoleClient(), agent.id) const reference = references.find( (item) => item.app_id === workflow.id || item.app_name === workflow.name, ) diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index e3ba96d1635..79b0f06aa40 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -2,7 +2,7 @@ import type { DifyWorld } from '../../support/world' import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { setAgentApiAccess, setAgentSiteAccess } from '../../agent-v2/support/access-point' +import { enableAgentWebApp } from '../../agent-v2/support/access-point' import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, @@ -12,18 +12,25 @@ import { } from './access-point-helpers' Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) { - await publishAgentWithPublishableDraft(getCurrentAgentId(this)) + await publishAgentWithPublishableDraft(this.getConsoleClient(), getCurrentAgentId(this)) }) Given( /^Agent v2 (Web app|Backend service API) access has been enabled via API$/, async function (this: DifyWorld, surface: AccessSurfaceName) { if (surface === 'Web app') { - await setAgentSiteAccess(getCurrentAgentId(this), true) + this.agentBuilder.accessPoint.webAppURL = await enableAgentWebApp( + this.getConsoleClient(), + getCurrentAgentId(this), + ) return } - const apiAccess = await setAgentApiAccess(getCurrentAgentId(this), true) + const agentId = getCurrentAgentId(this) + const apiAccess = await this.getConsoleClient().agent.byAgentId.apiEnable.post({ + body: { enable_api: true }, + params: { agent_id: agentId }, + }) this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url }, ) 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 a462bb830f7..bce34d4ff54 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -1,9 +1,8 @@ -import type { PostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' +import { zPostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' -import { getAgentComposerDraft, getTestAgent, publishAgent } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -19,8 +18,10 @@ import { openAgentKnowledgeRetrievalDialog, } from './configure-helpers' -const getComposerInheritanceSnapshot = async (agentId: string) => { - const draft = await getAgentComposerDraft(agentId) +const getComposerInheritanceSnapshot = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) const soul = draft.agent_soul ?? {} const model = asRecord(soul.model) const prompt = asRecord(soul.prompt) @@ -68,7 +69,11 @@ const getComposerInheritanceSnapshot = async (agentId: string) => { Given( 'the preseeded Agent v2 {string} has been published via API', async function (this: DifyWorld, agentName: string) { - await publishAgent(getPreseededAgent(this, agentName).id) + const agentId = getPreseededAgent(this, agentName).id + await this.getConsoleClient().agent.byAgentId.publish.post({ + body: { version_note: 'E2E publish' }, + params: { agent_id: agentId }, + }) }, ) @@ -100,7 +105,7 @@ When( const copyResponse = await copyResponsePromise expect(copyResponse.status()).toBe(201) - const copiedAgent = (await copyResponse.json()) as PostAgentByAgentIdCopyResponse + const copiedAgent = zPostAgentByAgentIdCopyResponse.parse(await copyResponse.json()) if (!copiedAgent.id) throw new Error('Agent v2 duplicate response did not include a copied Agent ID.') @@ -183,11 +188,12 @@ Then( 'Stable chat model fixture setup must run before asserting the duplicated Agent.', ) + const client = this.getConsoleClient() const [sourceDetail, duplicatedDetail, sourceSnapshot, duplicatedSnapshot] = await Promise.all([ - getTestAgent(sourceAgent.id), - getTestAgent(duplicatedAgentId), - getComposerInheritanceSnapshot(sourceAgent.id), - getComposerInheritanceSnapshot(duplicatedAgentId), + client.agent.byAgentId.get({ params: { agent_id: sourceAgent.id } }), + client.agent.byAgentId.get({ params: { agent_id: duplicatedAgentId } }), + getComposerInheritanceSnapshot(this, sourceAgent.id), + getComposerInheritanceSnapshot(this, duplicatedAgentId), ]) expect(duplicatedDetail.id).toBe(duplicatedAgentId) @@ -226,7 +232,9 @@ Then( await expect .poll( async () => { - const draft = await getAgentComposerDraft(sourceAgent.id) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: sourceAgent.id }, + }) return asString(asRecord(draft.agent_soul?.prompt).system_prompt) }, diff --git a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts index 25c0cb69973..228a54eaa99 100644 --- a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts @@ -1,6 +1,6 @@ -import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zPostAgentResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' @@ -30,7 +30,7 @@ When('I create an Agent v2 test agent from the Agent Roster', async function (th const createResponse = await createResponsePromise expect(createResponse.ok()).toBe(true) - const createdAgent = (await createResponse.json()) as AgentAppDetailWithSite + const createdAgent = zPostAgentResponse.parse(await createResponse.json()) this.createdAgentIds.push(createdAgent.id) this.lastCreatedAgentName = createdAgent.name this.lastCreatedAgentRole = createdAgent.role ?? undefined diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index ff2e1c62e40..4e07595de4f 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -1,12 +1,12 @@ +import type { AgentBuildDraftResponse } from '@dify/contracts/api/console/agent/types.gen' import type { Page, Response } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { readFile } from 'node:fs/promises' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft, saveAgentComposerDraft } from '../../agent-v2/support/agent' +import { saveAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuildDraftExists, - getAgentBuildDraft, saveAgentBuildDraft, } from '../../agent-v2/support/agent-build-draft' import { @@ -47,8 +47,7 @@ const getBuildNoteFileButton = (page: Page) => .filter({ hasText: BUILD_NOTE_FILE_NAME }) .filter({ hasText: BUILD_NOTE_GENERATED_BADGE }) -const getConfigNote = (value: Awaited>) => - value.agent_soul?.config_note ?? '' +const getConfigNote = (value: AgentBuildDraftResponse) => value.agent_soul?.config_note ?? '' const getLastBuildChatAnswerText = async (page: Page) => { const answer = page.getByTestId('chat-answer-container').last() @@ -65,7 +64,8 @@ const saveSupportedBuildDraft = async ( { retainSkillInNormalDraft }: { retainSkillInNormalDraft: boolean }, ) => { const agentId = getCurrentAgentId(world) - const configFile = await uploadAgentConfigFileToDraft({ + const client = world.getConsoleClient() + const configFile = await uploadAgentConfigFileToDraft(client, { agentId, fileName: agentBuilderTestMaterials.smallFile, filePath: getAgentBuilderTestMaterialPath('smallFile'), @@ -85,11 +85,11 @@ const saveSupportedBuildDraft = async ( : updatedAgentSoulConfig const configSkills = [skill] - await saveAgentComposerDraft(agentId, { + await saveAgentComposerDraft(client, agentId, { ...normalConfig, ...(retainSkillInNormalDraft ? { config_skills: configSkills } : {}), }) - await saveAgentBuildDraft(agentId, { + await saveAgentBuildDraft(client, agentId, { ...updatedConfig, config_files: [configFile], config_skills: configSkills, @@ -123,7 +123,11 @@ Given( ) Given('an Agent v2 Build draft uses the updated E2E prompt', async function (this: DifyWorld) { - await saveAgentBuildDraft(getCurrentAgentId(this), updatedAgentSoulConfig) + await saveAgentBuildDraft( + this.getConsoleClient(), + getCurrentAgentId(this), + updatedAgentSoulConfig, + ) }) Given( @@ -135,6 +139,7 @@ Given( ) await saveAgentBuildDraft( + this.getConsoleClient(), getCurrentAgentId(this), createAgentSoulConfigWithModel( updatedAgentSoulConfig, @@ -287,9 +292,16 @@ Then( async function (this: DifyWorld) { try { await expect - .poll(async () => getConfigNote(await getAgentBuildDraft(getCurrentAgentId(this))), { - timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.buildDraft.get({ + params: { agent_id: agentId }, + }) + return getConfigNote(draft) + }, + { timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS }, + ) .toContain(BUILD_NOTE_MARKER) } catch (error) { const lastAnswerText = await getLastBuildChatAnswerText(this.getPage()) @@ -317,7 +329,9 @@ Then( Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) { await expect - .poll(async () => agentBuildDraftExists(getCurrentAgentId(this)), { timeout: 30_000 }) + .poll(async () => agentBuildDraftExists(this.getConsoleClient(), getCurrentAgentId(this)), { + timeout: 30_000, + }) .toBe(false) }) @@ -371,8 +385,13 @@ Then( async function (this: DifyWorld) { await expect .poll( - async () => - (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.config_note ?? '', + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + return draft.agent_soul?.config_note ?? '' + }, { timeout: 30_000 }, ) .not.toContain(BUILD_NOTE_MARKER) @@ -385,7 +404,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul const variables = agentSoul?.env?.variables ?? [] return { @@ -412,7 +435,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul const variables = agentSoul?.env?.variables ?? [] return { @@ -439,7 +466,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul return ( agentSoul?.config_skills?.filter( (skill) => skill.name === agentBuilderPreseededResources.summarySkill, diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index 8ae2917b759..da76a65d44c 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -1,8 +1,8 @@ import type { Locator } from '@playwright/test' import type { AgentComposerEnvVariable } from '../../agent-v2/support/agent-soul' import type { DifyWorld } from '../../support/world' +import { zPostAgentByAgentIdConfigFilesResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/agent-drive' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' import { @@ -42,8 +42,12 @@ export const getEnvVariableKey = (variable: AgentComposerEnvVariable) => export const getAgentEnvVariableValue = (variables: AgentComposerEnvVariable[], key: string) => variables.find((variable) => getEnvVariableKey(variable) === key)?.value -export const getAgentEnvVariables = async (agentId: string) => - (await getAgentComposerDraft(agentId)).agent_soul?.env?.variables ?? [] +export const getAgentEnvVariables = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.env?.variables ?? [] +} export const uploadAgentConfigFile = async ( world: DifyWorld, @@ -71,7 +75,7 @@ export const uploadAgentConfigFile = async ( await dialog.getByRole('button', { name: 'Upload' }).click() const commitResponse = await commitResponsePromise expect(commitResponse.status()).toBe(201) - const committed = (await commitResponse.json()) as { file?: { name?: string } } + const committed = zPostAgentByAgentIdConfigFilesResponse.parse(await commitResponse.json()) await expect(dialog).not.toBeVisible({ timeout: 30_000 }) const committedName = committed.file?.name @@ -118,9 +122,10 @@ export const expectAgentConfigFileSaved = async ( await expect .poll( async () => { - const file = (await getAgentComposerDraft(agentId)).agent_soul?.config_files?.find( - (file) => file.name === fileName, - ) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + const file = draft.agent_soul?.config_files?.find((file) => file.name === fileName) return file ? { @@ -145,7 +150,7 @@ export const expectAgentModelRequiredFeedback = async (page: ReturnType { const agentId = getCurrentAgentId(world) - const skill = await uploadAgentConfigSkillToDraft({ + const skill = await uploadAgentConfigSkillToDraft(world.getConsoleClient(), { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -241,9 +246,16 @@ export const expectAgentEnvVariableHidden = async (world: DifyWorld, key: string export const expectNormalAgentPromptDraft = async (world: DifyWorld) => { await expect - .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(world))).agent_soul?.prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(world) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.prompt + }, + { timeout: 30_000 }, + ) .toEqual({ system_prompt: normalAgentPrompt }) } diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index 89595a852f7..b4ada8ef204 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -6,7 +6,6 @@ import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure import { createConfiguredTestAgent, createTestAgent, - getAgentComposerDraft, getAgentConfigurePath, saveAgentComposerDraft, } from '../../agent-v2/support/agent' @@ -49,16 +48,22 @@ async function selectAgentModel(page: Page, modelName: string) { await page.getByRole('option', { name: new RegExp(`${escapedModelName}(?:\\s|$)`) }).click() } -async function expectAgentComposerPrompt(agentId: string, prompt: string) { +async function expectAgentComposerPrompt(world: DifyWorld, agentId: string, prompt: string) { await expect - .poll(async () => (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.prompt?.system_prompt + }, + { timeout: 30_000 }, + ) .toBe(prompt) } Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) { - const agent = await createTestAgent() + const agent = await createTestAgent(this.getConsoleClient()) this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined @@ -67,7 +72,7 @@ Given('an Agent v2 test agent has been created via API', async function (this: D Given( 'a basic configured Agent v2 test agent has been created via API', async function (this: DifyWorld) { - const agent = await createConfiguredTestAgent() + const agent = await createConfiguredTestAgent(this.getConsoleClient()) this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined @@ -78,7 +83,7 @@ Given('a runnable Agent v2 test agent has been created via API', async function if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create a runnable Agent v2 test agent after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.stableModel, @@ -98,7 +103,7 @@ Given( ) } - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.agentDecisionModel, @@ -113,15 +118,20 @@ Given( Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - await saveAgentComposerDraft(agentId) + await saveAgentComposerDraft(this.getConsoleClient(), agentId) }) Given('the Agent v2 composer draft uses the normal E2E prompt', async function (this: DifyWorld) { - await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig) + await saveAgentComposerDraft( + this.getConsoleClient(), + getCurrentAgentId(this), + normalAgentSoulConfig, + ) }) Given('the Agent v2 composer draft is publishable', async function (this: DifyWorld) { await saveAgentComposerDraft( + this.getConsoleClient(), getCurrentAgentId(this), createPublishableAgentSoulConfig(normalAgentSoulConfig), ) @@ -131,7 +141,7 @@ Given( 'the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - const upload = await uploadAgentDriveSkill({ + const upload = await uploadAgentDriveSkill(this.getConsoleClient(), { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -145,7 +155,7 @@ Given( Then( 'the Agent v2 test agent should include drive skill {string}', async function (this: DifyWorld, skillName: string) { - const skills = await getAgentDriveSkills(getCurrentAgentId(this)) + const skills = await getAgentDriveSkills(this.getConsoleClient(), getCurrentAgentId(this)) expect(skills.map((skill) => skill.name)).toContain(skillName) }, @@ -257,7 +267,7 @@ When('I save the Agent v2 prompt from the first configure tab', async function ( await fillAgentPromptEditor(this.getPage(), concurrentFirstAgentPrompt) await waitForAgentConfigureAutosaved(this.getPage()) - await expectAgentComposerPrompt(agentId, concurrentFirstAgentPrompt) + await expectAgentComposerPrompt(this, agentId, concurrentFirstAgentPrompt) }) When('I save the Agent v2 prompt from the second configure tab', async function (this: DifyWorld) { @@ -268,7 +278,7 @@ When('I save the Agent v2 prompt from the second configure tab', async function await fillAgentPromptEditor(concurrentPage, concurrentSecondAgentPrompt) await waitForAgentConfigureAutosaved(concurrentPage) - await expectAgentComposerPrompt(agentId, concurrentSecondAgentPrompt) + await expectAgentComposerPrompt(this, agentId, concurrentSecondAgentPrompt) }) When('I refresh both Agent v2 configure tabs', async function (this: DifyWorld) { @@ -360,7 +370,10 @@ Then( await expect .poll( async () => { - const prompt = (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const prompt = draft.agent_soul?.prompt?.system_prompt if (prompt && concurrentAgentPrompts.includes(prompt)) savedPrompt = prompt return !!savedPrompt @@ -392,9 +405,16 @@ Then( 'the normal Agent v2 draft should use the updated E2E prompt', async function (this: DifyWorld) { await expect - .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + return draft.agent_soul?.prompt + }, + { timeout: 30_000 }, + ) .toEqual({ system_prompt: updatedAgentPrompt }) }, ) @@ -407,7 +427,11 @@ Then('the Agent v2 draft should use the stable E2E model', async function (this: await expect .poll( async () => { - const model = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.model + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const model = draft.agent_soul?.model const modelConfig = typeof model === 'object' && model !== null && !Array.isArray(model) ? (model as Record) @@ -438,8 +462,11 @@ Then( await expect .poll( async () => { - const draftModel = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - ?.model + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const draftModel = draft.agent_soul?.model const modelConfig = typeof draftModel === 'object' && draftModel !== null && !Array.isArray(draftModel) ? (draftModel as Record) diff --git a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts index 38573073441..6713a0acad7 100644 --- a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts +++ b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts @@ -1,7 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources' import { getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials' import { @@ -97,7 +96,10 @@ Then( await expect .poll( async () => { - const env = (await getAgentComposerDraft(agentId)).agent_soul?.env + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const env = draft.agent_soul?.env const variable = env?.variables?.find( (item) => getEnvVariableKey(item) === agentBuilderFixedInputs.envPlainKey, ) @@ -126,7 +128,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -152,7 +154,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -178,7 +180,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -211,7 +213,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { importedValue: getAgentEnvVariableValue( diff --git a/e2e/features/step-definitions/agent-v2/fixtures.steps.ts b/e2e/features/step-definitions/agent-v2/fixtures.steps.ts index b6e52d9422d..1fb531ecc7a 100644 --- a/e2e/features/step-definitions/agent-v2/fixtures.steps.ts +++ b/e2e/features/step-definitions/agent-v2/fixtures.steps.ts @@ -18,19 +18,25 @@ import { import { requirePreseededTool } from '../../agent-v2/support/fixtures/tools' Given('the Agent Builder stable chat model is available', async function (this: DifyWorld) { - const stableModel = await requireAgentBuilderStableChatModel(this) + const stableModel = await requireAgentBuilderStableChatModel(this, this.getConsoleClient()) this.agentBuilder.fixtures.stableModel = stableModel }) Given('the workspace default speech-to-text model is active', async function (this: DifyWorld) { - const speechToTextModel = await requireAgentBuilderSpeechToTextModel(this) + const speechToTextModel = await requireAgentBuilderSpeechToTextModel( + this, + this.getConsoleClient(), + ) this.agentBuilder.fixtures.speechToTextModel = speechToTextModel }) Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) { - const agentDecisionModel = await requireAgentBuilderAgentDecisionChatModel(this) + const agentDecisionModel = await requireAgentBuilderAgentDecisionChatModel( + this, + this.getConsoleClient(), + ) this.agentBuilder.fixtures.agentDecisionModel = agentDecisionModel }) @@ -42,7 +48,7 @@ Given('the Agent v2 runtime backend is available', async function (this: DifyWor Given( 'the Agent Builder preseeded Agent {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededAgent(this, resourceName) + const resource = await requirePreseededAgent(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -51,7 +57,7 @@ Given( Given( 'the Agent Builder preseeded workflow {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededWorkflow(this, resourceName) + const resource = await requirePreseededWorkflow(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -60,7 +66,7 @@ Given( Given( 'the Agent Builder preseeded dataset {string} is indexed and ready', async function (this: DifyWorld, resourceName: string) { - const resource = await requireReadyPreseededDataset(this, resourceName) + const resource = await requireReadyPreseededDataset(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -69,7 +75,7 @@ Given( Given( 'the Agent Builder preseeded tool {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededTool(this, resourceName) + const resource = await requirePreseededTool(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -78,7 +84,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the core fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededFullConfigAgentCoreConfiguration(this, agentName) + const resource = await requirePreseededFullConfigAgentCoreConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[`${agentName} / core fixture configuration`] = resource @@ -88,7 +98,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the tool state fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededToolStatesAgentConfiguration(this, agentName) + const resource = await requirePreseededToolStatesAgentConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[ `${agentName} / tool state fixture configuration` @@ -99,7 +113,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the dual retrieval fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededDualRetrievalAgentConfiguration(this, agentName) + const resource = await requirePreseededDualRetrievalAgentConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[ `${agentName} / dual retrieval fixture configuration` @@ -110,7 +128,12 @@ Given( Given( 'the Agent Builder preseeded Agent {string} is referenced by workflow {string}', async function (this: DifyWorld, agentName: string, workflowName: string) { - const resource = await requirePreseededAgentWorkflowReference(this, agentName, workflowName) + const resource = await requirePreseededAgentWorkflowReference( + this, + this.getConsoleClient(), + agentName, + workflowName, + ) this.agentBuilder.fixtures.preseededResources[`${agentName} / ${workflowName}`] = resource }, diff --git a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts index 54cf64b1ab9..a409ebbea56 100644 --- a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts +++ b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts @@ -2,7 +2,7 @@ import type { Locator } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { agentBuilderFixedInputs, agentBuilderPreseededResources, @@ -31,8 +31,10 @@ const getPreseededKnowledgeBase = (world: DifyWorld) => { const getKnowledgeSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Knowledge Retrieval' }) -const getKnowledgeSets = async (agentId: string) => { - const draft = await getAgentComposerDraft(agentId) +const getKnowledgeSets = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) return asArray(asRecord(draft.agent_soul?.knowledge).sets) } @@ -96,7 +98,7 @@ const expectKnowledgeRetrievalDraft = async ( await expect .poll( async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSets = await getKnowledgeSets(world, agentId) const knowledgeSet = asRecord(knowledgeSets[0]) const datasets = asArray(knowledgeSet.datasets) const query = asRecord(knowledgeSet.query) @@ -125,7 +127,7 @@ Given( 'a knowledge-backed Agent v2 test agent has been created via API', async function (this: DifyWorld) { const knowledgeBase = getPreseededKnowledgeBase(this) - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithKnowledgeDataset(normalAgentSoulConfig, { id: knowledgeBase.id, name: knowledgeBase.name, @@ -254,7 +256,7 @@ Then( await expect .poll( async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSets = await getKnowledgeSets(this, agentId) return knowledgeSets.some((set) => asArray(asRecord(set).datasets).some((dataset) => { diff --git a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts index ead1abf1bcd..77917464595 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -22,12 +22,16 @@ const getCurrentAppId = (world: DifyWorld) => { const parseDeclaredOutputs = (value: unknown): DeclaredOutputConfig[] => z.array(zDeclaredOutputConfig).optional().default([]).parse(value) -const getDeclaredOutputsFromDraft = async (appId: string): Promise => { - const data = await getAgentV2WorkflowNodeData(appId) +const getDeclaredOutputsFromDraft = async ( + world: DifyWorld, + appId: string, +): Promise => { + const data = await getAgentV2WorkflowNodeData(world.getConsoleClient(), appId) return parseDeclaredOutputs(data.agent_declared_outputs) } -const getOutputVariablesFromDraft = async (appId: string) => getDeclaredOutputsFromDraft(appId) +const getOutputVariablesFromDraft = async (world: DifyWorld, appId: string) => + getDeclaredOutputsFromDraft(world, appId) const waitForWorkflowDraftSave = (world: DifyWorld, appId: string) => world @@ -181,7 +185,7 @@ Then( await expect .poll( async () => { - const outputs = await getOutputVariablesFromDraft(appId) + const outputs = await getOutputVariablesFromDraft(this, appId) return expectedOutputVariables.map((expected) => { const output = outputs.find((item) => item.name === expected.name) @@ -223,7 +227,7 @@ Then( await expect .poll( async () => { - const outputs = await getDeclaredOutputsFromDraft(appId) + const outputs = await getDeclaredOutputsFromDraft(this, appId) const response = outputs.find((output) => output.name === 'response') return { @@ -299,7 +303,7 @@ async function expectAgentTaskOutputReference( await expect .poll( async () => { - const data = await getAgentV2WorkflowNodeData(appId) + const data = await getAgentV2WorkflowNodeData(world.getConsoleClient(), appId) const outputs = parseDeclaredOutputs(data.agent_declared_outputs) const expectedOutput = outputs.find((output) => output.name === expectedName) diff --git a/e2e/features/step-definitions/agent-v2/publish.steps.ts b/e2e/features/step-definitions/agent-v2/publish.steps.ts index aaf2a81868f..dcfaaf69e5f 100644 --- a/e2e/features/step-definitions/agent-v2/publish.steps.ts +++ b/e2e/features/step-definitions/agent-v2/publish.steps.ts @@ -2,7 +2,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure' -import { getTestAgent } from '../../agent-v2/support/agent' import { expectAgentModelRequiredFeedback, getCurrentAgentId } from './configure-helpers' When('I publish the Agent v2 draft', async function (this: DifyWorld) { @@ -30,9 +29,16 @@ Then( Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) { await expect - .poll(async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const agent = await this.getConsoleClient().agent.byAgentId.get({ + params: { agent_id: agentId }, + }) + return agent.active_config_is_published + }, + { timeout: 30_000 }, + ) .toBe(false) }) @@ -47,7 +53,14 @@ Then('the Agent v2 draft should be published and up to date', async function (th await expect(page.getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('status', { name: /^Up to date\./ })).toBeVisible() await expect(page.getByText('Up to date')).toBeVisible() - await expect.poll(async () => (await getTestAgent(agentId)).active_config_is_published).toBe(true) + await expect + .poll(async () => { + const agent = await this.getConsoleClient().agent.byAgentId.get({ + params: { agent_id: agentId }, + }) + return agent.active_config_is_published + }) + .toBe(true) }) Then( diff --git a/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts b/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts index e78769e9623..b0a0d36afbc 100644 --- a/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts +++ b/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts @@ -27,7 +27,7 @@ Given( ) } - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithSpeechToText(normalAgentSoulConfig), }) this.createdAgentIds.push(agent.id) diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index bdd5c26e4e5..d1fd0099a0b 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -3,10 +3,9 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' -import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens, - agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../../agent-v2/support/agent-builder-resources' import { @@ -40,7 +39,9 @@ const expectJsonReplaceToolDraft = async (world: DifyWorld) => { await expect .poll( async () => { - const draft = await getAgentComposerDraft(agentId) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) return hasToolEntry(tools, tool) @@ -116,7 +117,7 @@ Given( if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create a JSON Replace runtime Agent after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithDifyTool( createAgentSoulConfigWithModel( { @@ -158,26 +159,6 @@ When( }, ) -When( - 'I search for the missing Agent v2 tool from the Tools selector', - async function (this: DifyWorld) { - const toolsSection = getToolsSection(this) - - await expect(toolsSection).toBeVisible({ timeout: 30_000 }) - await toolsSection.getByRole('button', { name: 'Add tool' }).click() - - const search = getToolSelectorSearch(this) - await expect(search).toBeVisible() - await search.fill(agentBuilderFixedInputs.missingToolSearchWithSuffix) - }, -) - -When('I clear the Agent v2 tool selector search', async function (this: DifyWorld) { - const search = getToolSelectorSearch(this) - - await search.fill('') -}) - Then( 'the Agent v2 JSON Replace tool should be saved in the Agent v2 draft', async function (this: DifyWorld) { @@ -248,26 +229,3 @@ Then( ) }, ) - -Then( - 'I should see the unavailable Agent v2 installed-tool search applied', - async function (this: DifyWorld) { - const page = this.getPage() - const search = getToolSelectorSearch(this) - - await expect(search).toHaveValue(agentBuilderFixedInputs.missingToolSearchWithSuffix) - await expect(page.getByText('All tools', { exact: true })).not.toBeVisible() - }, -) - -Then( - 'I should see the Agent v2 tool selector ready for another search', - async function (this: DifyWorld) { - const page = this.getPage() - const search = getToolSelectorSearch(this) - - await expect(search).toHaveValue('') - await expect(page.getByText('No integrations were found')).not.toBeVisible() - await expect(page.getByText('All tools')).toBeVisible() - }, -) diff --git a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts index 36bb0fa0db2..3a8d63fc65b 100644 --- a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts +++ b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts @@ -3,7 +3,7 @@ import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' -import { createConfiguredTestAgent, publishAgent } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { createAgentSoulConfigWithModel, normalAgentPrompt, @@ -17,7 +17,8 @@ Given( if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create an Agent v2 workflow node after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const client = this.getConsoleClient() + const agent = await createConfiguredTestAgent(client, { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.stableModel, @@ -26,13 +27,20 @@ Given( this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined - await publishAgent(agent.id) + await client.agent.byAgentId.publish.post({ + body: { version_note: 'E2E publish' }, + params: { agent_id: agent.id }, + }) - const app = await createTestApp(createE2EResourceName('App', 'workflow-agent-v2'), 'workflow') + const app = await createTestApp( + client, + createE2EResourceName('App', 'workflow-agent-v2'), + 'workflow', + ) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncAgentV2WorkflowDraft(app.id, agent.id) + await syncAgentV2WorkflowDraft(client, app.id, agent.id) }, ) diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index 3285e63baac..88113afa3fb 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -1,5 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zPostAppsResponse } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' import { openBlankAppCreation } from '../../../support/apps' import { createE2EResourceName } from '../../../support/naming' @@ -43,7 +44,7 @@ When('I confirm app creation', async function (this: DifyWorld) { const response = await responsePromise expect(response.ok()).toBe(true) - const createdApp = (await response.json()) as { id?: string; mode?: string } + const createdApp = zPostAppsResponse.parse(await response.json()) if (!createdApp.id) throw new Error('Create app response did not include an app ID.') const expectedMode = this.lastSelectedAppType diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index 989defab4ce..c572efa7ca8 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -1,12 +1,13 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { zPostAppsByAppIdCopyResponse } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { const name = createE2EResourceName('App', 'Test') - const app = await createTestApp(name, 'completion') + const app = await createTestApp(this.getConsoleClient(), name, 'completion') this.lastCreatedAppName = app.name this.createdAppIds.push(app.id) }) @@ -40,7 +41,7 @@ When('I confirm the app duplication', async function (this: DifyWorld) { await page.getByRole('button', { exact: true, name: 'Duplicate' }).click() const response = await responsePromise expect(response.ok()).toBe(true) - const copiedApp = (await response.json()) as { id?: string } + const copiedApp = zPostAppsByAppIdCopyResponse.parse(await response.json()) if (!copiedApp.id) throw new Error('Duplicate app response did not include an app ID.') expect(copiedApp.id).not.toBe(sourceAppId) this.createdAppIds.push(copiedApp.id) diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index b06d63942b5..93af0e8865d 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -2,17 +2,21 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' -import { enableAppSiteAndGetURL } from '../../../support/api/web-apps' -import { publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api/workflows' +import { getAppSiteURL } from '../../../support/api/web-apps' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { - const app = await createTestApp(createE2EResourceName('App', 'Share'), 'workflow') + const client = this.getConsoleClient() + const app = await createTestApp(client, createE2EResourceName('App', 'Share'), 'workflow') this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncRunnableWorkflowDraft(app.id) - await publishWorkflowApp(app.id) - this.shareURL = await enableAppSiteAndGetURL(app.id) + await syncRunnableWorkflowDraft(client, app.id) + await client.apps.byAppId.workflows.publish.post({ + body: { marked_comment: '', marked_name: '' }, + params: { app_id: app.id }, + }) + this.shareURL = getAppSiteURL(await client.apps.byAppId.get({ params: { app_id: app.id } })) }) When('I open the shared app URL', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts index 0ea4c980f5d..caf5b22e1ae 100644 --- a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -8,7 +8,7 @@ Given( 'there is an existing E2E completion app available for testing', async function (this: DifyWorld) { const name = createE2EResourceName('App', 'Test') - const app = await createTestApp(name, 'completion') + const app = await createTestApp(this.getConsoleClient(), name, 'completion') this.lastCreatedAppName = app.name this.createdAppIds.push(app.id) }, diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts index dc4baafee42..d7f9d39d2e9 100644 --- a/e2e/features/step-definitions/apps/web-app-service.steps.ts +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -2,19 +2,23 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' -import { getAppSiteDetail, getAppSiteURL } from '../../../support/api/web-apps' -import { publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api/workflows' +import { getAppSiteURL } from '../../../support/api/web-apps' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' import { baseURL, defaultLocale } from '../../../test-env' Given('a new runnable workflow app has been published', async function (this: DifyWorld) { - const app = await createTestApp(createE2EResourceName('App', 'WebApp'), 'workflow') + const client = this.getConsoleClient() + const app = await createTestApp(client, createE2EResourceName('App', 'WebApp'), 'workflow') this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncRunnableWorkflowDraft(app.id) - await publishWorkflowApp(app.id) + await syncRunnableWorkflowDraft(client, app.id) + await client.apps.byAppId.workflows.publish.post({ + body: { marked_comment: '', marked_name: '' }, + params: { app_id: app.id }, + }) - const appDetail = await getAppSiteDetail(app.id) + const appDetail = await client.apps.byAppId.get({ params: { app_id: app.id } }) expect(appDetail.enable_site).toBe(true) this.shareURL = getAppSiteURL(appDetail) }) diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts index 76e0aa2d369..e6e7cd4362a 100644 --- a/e2e/features/step-definitions/apps/workflow-run.steps.ts +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -7,7 +7,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this: const appId = this.createdAppIds.at(-1) if (!appId) throw new Error('No app ID found. Run "a \\"workflow\\" app has been created via API" first.') - await syncRunnableWorkflowDraft(appId) + await syncRunnableWorkflowDraft(this.getConsoleClient(), appId) }) When('I run the workflow', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index fedec7a3944..c647af69f7e 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -9,7 +9,11 @@ import { createE2EResourceName } from '../../../support/naming' Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { const appMode = zCreateAppPayload.shape.mode.parse(mode) - const app = await createTestApp(createE2EResourceName('App', appMode), appMode) + const app = await createTestApp( + this.getConsoleClient(), + createE2EResourceName('App', appMode), + appMode, + ) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name }) @@ -17,7 +21,7 @@ Given('a {string} app has been created via API', async function (this: DifyWorld Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { const appId = this.createdAppIds.at(-1) if (!appId) throw new Error('No app is available for workflow draft setup.') - await syncMinimalWorkflowDraft(appId) + await syncMinimalWorkflowDraft(this.getConsoleClient(), appId) }) When('I open the app from the app list', async function (this: DifyWorld) { diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 9155684a7a3..006277d5649 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -8,18 +8,9 @@ import { fileURLToPath } from 'node:url' import { After, AfterAll, Before, setDefaultTimeout, Status } from '@cucumber/cucumber' import { chromium, webkit } from '@playwright/test' import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' -import { deleteTestApp } from '../../support/api/apps' -import { deleteTestDataset } from '../../support/api/datasets' -import { deleteBuiltinToolCredential } from '../../support/api/tools' import { runCleanupTasks, shouldFailForCleanupErrors } from '../../support/cleanup' import { getVoiceInputTestMaterialPath } from '../../support/test-materials' import { baseURL, cucumberHeadless, cucumberSlowMo, e2eBrowser } from '../../test-env' -import { deleteTestAgent } from '../agent-v2/support/agent' -import { - deleteAgentConfigFile, - deleteAgentConfigSkill, - deleteAgentDriveFile, -} from '../agent-v2/support/agent-drive' const e2eRoot = fileURLToPath(new URL('../..', import.meta.url)) const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts') @@ -165,31 +156,57 @@ After( const cleanupTasks: CleanupTask[] = [ ...this.createdAgentConfigSkills.toReversed().map((skill) => ({ label: `Delete Agent config skill ${skill.name}`, - run: () => deleteAgentConfigSkill(skill.agentId, skill.name), + run: async () => { + await this.getConsoleClient().agent.byAgentId.config.skills.byName.delete({ + params: { agent_id: skill.agentId, name: skill.name }, + }) + }, })), ...this.createdAgentConfigFiles.toReversed().map((file) => ({ label: `Delete Agent config file ${file.name}`, - run: () => deleteAgentConfigFile(file.agentId, file.name), + run: async () => { + await this.getConsoleClient().agent.byAgentId.config.files.byName.delete({ + params: { agent_id: file.agentId, name: file.name }, + }) + }, })), ...this.createdAgentDriveFiles.toReversed().map((file) => ({ label: `Delete Agent drive file ${file.key}`, - run: () => deleteAgentDriveFile(file.agentId, file.key), + run: async () => { + await this.getConsoleClient().agent.byAgentId.files.delete({ + params: { agent_id: file.agentId }, + query: { key: file.key }, + }) + }, })), ...this.createdAppIds.toReversed().map((id) => ({ label: `Delete app ${id}`, - run: () => deleteTestApp(id), + run: async () => { + await this.getConsoleClient().apps.byAppId.delete({ params: { app_id: id } }) + }, })), ...this.createdAgentIds.toReversed().map((id) => ({ label: `Delete Agent ${id}`, - run: () => deleteTestAgent(id), + run: async () => { + await this.getConsoleClient().agent.byAgentId.delete({ params: { agent_id: id } }) + }, })), ...this.createdDatasetIds.toReversed().map((id) => ({ label: `Delete dataset ${id}`, - run: () => deleteTestDataset(id), + run: async () => { + await this.getConsoleClient().datasets.byDatasetId.delete({ params: { dataset_id: id } }) + }, })), ...this.createdBuiltinToolCredentials.toReversed().map((credential) => ({ label: `Delete builtin tool credential ${credential.provider}/${credential.credentialId}`, - run: () => deleteBuiltinToolCredential(credential.provider, credential.credentialId), + run: async () => { + await this.getConsoleClient().workspaces.current.toolProvider.builtin.byProvider.delete.post( + { + body: { credential_id: credential.credentialId }, + params: { provider: credential.provider }, + }, + ) + }, })), ] diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 53a13f40163..3cbabd84542 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -1,10 +1,13 @@ import type { IWorldOptions } from '@cucumber/cucumber' -import type { Browser, BrowserContext, Download, Page } from '@playwright/test' +import type { APIRequestContext, Browser, BrowserContext, Download, Page } from '@playwright/test' import type { AuthSessionMetadata } from '../../fixtures/auth' +import type { ConsoleClient } from '../../support/api/console-client' import { setWorldConstructor, World } from '@cucumber/cucumber' +import { request } from '@playwright/test' import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth' +import { createConsoleClient } from '../../support/api/console-client' import { runCleanupTasks } from '../../support/cleanup' -import { baseURL, defaultLocale } from '../../test-env' +import { apiURL, baseURL, defaultLocale } from '../../test-env' export type ScenarioCleanup = () => Promise | void export type CreatedAgentDriveFile = { @@ -76,6 +79,8 @@ export type AgentBuilderWorldState = ReturnType { if (message.type() === 'error') this.consoleErrors.push(message.text()) }) @@ -158,6 +168,13 @@ export class DifyWorld extends World { return this.page } + getConsoleClient() { + if (!this.consoleClient) + throw new Error('Console API client has not been initialized for this scenario.') + + return this.consoleClient + } + async getAuthSession() { this.session ??= await readAuthSessionMetadata() return this.session @@ -180,7 +197,10 @@ export class DifyWorld extends World { try { await this.context?.close() } finally { + await this.consoleRequestContext?.dispose() this.context = undefined + this.consoleRequestContext = undefined + this.consoleClient = undefined this.page = undefined this.session = undefined this.scenarioStartedAt = undefined diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 1467e66644f..582f23fadba 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -1,9 +1,10 @@ -import type { APIResponse, Browser, BrowserContext } from '@playwright/test' +import type { Browser } from '@playwright/test' import { Buffer } from 'node:buffer' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { apiURL, defaultBaseURL, defaultLocale } from '../test-env' +import { createConsoleClient } from '../support/api/console-client' +import { defaultBaseURL, defaultLocale } from '../test-env' export type AuthSessionMetadata = { adminEmail: string @@ -36,16 +37,6 @@ export const readAuthSessionMetadata = async () => { return JSON.parse(content) as AuthSessionMetadata } -const apiEndpoint = (pathname: string) => new URL(pathname, apiURL).toString() - -type SetupStatusResponse = { - step: 'not_started' | 'finished' -} - -type InitStatusResponse = { - status: 'not_started' | 'finished' -} - type AuthBootstrapResult = { mode: AuthSessionMetadata['mode'] usedInitPassword: boolean @@ -55,65 +46,41 @@ const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now() const encodeField = (value: string) => Buffer.from(value, 'utf8').toString('base64') -const assertAPIResponse = async (response: APIResponse, action: string) => { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error( - `${action} failed with ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`, - ) -} - -const getConsoleAPI = async (context: BrowserContext, pathname: string, deadline: number) => { - const response = await context.request.get(apiEndpoint(pathname), { - timeout: getRemainingTimeout(deadline), - }) - await assertAPIResponse(response, `GET ${pathname}`) - return response.json() as Promise -} - -const postConsoleAPI = async ( - context: BrowserContext, - pathname: string, +const validateInitPasswordIfNeeded = async ( + client: ReturnType, deadline: number, - data: Record, ) => { - const response = await context.request.post(apiEndpoint(pathname), { - data, - timeout: getRemainingTimeout(deadline), - }) - await assertAPIResponse(response, `POST ${pathname}`) -} - -const validateInitPasswordIfNeeded = async (context: BrowserContext, deadline: number) => { - const initStatus = await getConsoleAPI(context, '/console/api/init', deadline) + const options = { context: { timeoutMs: getRemainingTimeout(deadline) } } + const initStatus = await client.init.get(undefined, options) if (initStatus.status === 'finished') return false console.warn('[e2e] auth bootstrap: validating init password') - await postConsoleAPI(context, '/console/api/init', deadline, { password: initPassword }) + await client.init.post({ body: { password: initPassword } }, options) return true } const ensureAdminAccount = async ( - context: BrowserContext, + client: ReturnType, deadline: number, ): Promise => { - const setupStatus = await getConsoleAPI( - context, - '/console/api/setup', - deadline, - ) + const options = { context: { timeoutMs: getRemainingTimeout(deadline) } } + const setupStatus = await client.setup.get(undefined, options) let usedInitPassword = false if (setupStatus.step === 'not_started') { - usedInitPassword = await validateInitPasswordIfNeeded(context, deadline) + usedInitPassword = await validateInitPasswordIfNeeded(client, deadline) console.warn('[e2e] auth bootstrap: creating admin account') - await postConsoleAPI(context, '/console/api/setup', deadline, { - email: adminCredentials.email, - name: adminCredentials.name, - password: adminCredentials.password, - language: defaultLocale, - }) + await client.setup.post( + { + body: { + email: adminCredentials.email, + name: adminCredentials.name, + password: adminCredentials.password, + language: defaultLocale, + }, + }, + options, + ) return { mode: 'install', usedInitPassword } } @@ -121,13 +88,18 @@ const ensureAdminAccount = async ( return { mode: 'login', usedInitPassword } } -const loginAdmin = async (context: BrowserContext, deadline: number) => { +const loginAdmin = async (client: ReturnType, deadline: number) => { console.warn('[e2e] auth bootstrap: logging in admin') - await postConsoleAPI(context, '/console/api/login', deadline, { - email: adminCredentials.email, - password: encodeField(adminCredentials.password), - remember_me: true, - }) + await client.login.post( + { + body: { + email: adminCredentials.email, + password: encodeField(adminCredentials.password), + remember_me: true, + }, + }, + { context: { timeoutMs: getRemainingTimeout(deadline) } }, + ) } export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => { @@ -140,10 +112,11 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU baseURL, locale: defaultLocale, }) + const client = createConsoleClient({ requestContext: context.request, requireCsrfToken: false }) try { - const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline) - await loginAdmin(context, deadline) + const { mode, usedInitPassword } = await ensureAdminAccount(client, deadline) + await loginAdmin(client, deadline) await context.storageState({ path: authStatePath }) diff --git a/e2e/package.json b/e2e/package.json index 52ec01c30bd..562cfb6a46c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,6 +23,9 @@ "@cucumber/cucumber": "catalog:", "@dify/contracts": "workspace:*", "@dify/tsconfig": "workspace:*", + "@orpc/client": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi-client": "catalog:", "@playwright/test": "catalog:", "@t3-oss/env-core": "catalog:", "@types/node": "catalog:", diff --git a/e2e/scripts/seed.ts b/e2e/scripts/seed.ts index d609612c2db..9b1b13dc5c6 100644 --- a/e2e/scripts/seed.ts +++ b/e2e/scripts/seed.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { chromium } from '@playwright/test' import { createAgentV2SeedTasks } from '../features/agent-v2/support/seed' import { ensureAuthenticatedState } from '../fixtures/auth' +import { createStandaloneConsoleSession } from '../support/api/console-session' import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process' import { runSeedTasks, writeSeedReport } from '../support/seed' import { startWebServer, stopWebServer } from '../support/web-server' @@ -110,6 +111,7 @@ const main = async () => { const logDir = path.join(e2eDir, '.logs') let apiProcess: ManagedProcess | undefined let celeryProcess: ManagedProcess | undefined + let consoleSession: Awaited> | undefined await mkdir(logDir, { recursive: true }) @@ -128,8 +130,10 @@ const main = async () => { console.warn(`[seed] bootstrapping auth state against ${baseURL}`) await ensureAuth() + consoleSession = await createStandaloneConsoleSession() const results = await runSeedTasks(getTasks(options.pack, options.profile), { + consoleClient: consoleSession.client, dryRun: options.dryRun, resources: new Map(), }) @@ -144,6 +148,7 @@ const main = async () => { ) } } finally { + await consoleSession?.dispose() await stopWebServer() await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) diff --git a/e2e/support/api/apps.ts b/e2e/support/api/apps.ts index ae7ae30ffaf..a6976325013 100644 --- a/e2e/support/api/apps.ts +++ b/e2e/support/api/apps.ts @@ -1,36 +1,20 @@ import type { CreateAppPayload, PostAppsResponse } from '@dify/contracts/api/console/apps/types.gen' -import { zPostAppsResponse } from '@dify/contracts/api/console/apps/zod.gen' +import type { ConsoleClient } from './console-client' import { assertE2EResourceName, createE2EResourceName } from '../naming' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' export async function createTestApp( + client: ConsoleClient, name = createE2EResourceName('App'), mode: CreateAppPayload['mode'] = 'workflow', ): Promise { assertE2EResourceName(name, 'App') - const ctx = await createConsoleApiContext() - try { - const data = { - name, - mode, - icon_type: 'emoji', - icon: '🤖', - icon_background: '#FFEAD5', - } satisfies CreateAppPayload - const response = await ctx.post('/console/api/apps', { data }) - await expectApiResponseOK(response, `Create ${mode} app ${name}`) - return zPostAppsResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} + const body = { + name, + mode, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + } satisfies CreateAppPayload -export async function deleteTestApp(id: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/apps/${id}`) - await expectApiResponseOK(response, `Delete app ${id}`) - } finally { - await ctx.dispose() - } + return client.apps.post({ body }) } diff --git a/e2e/support/api/console-client.ts b/e2e/support/api/console-client.ts new file mode 100644 index 00000000000..7a6a24604b8 --- /dev/null +++ b/e2e/support/api/console-client.ts @@ -0,0 +1,52 @@ +import type { ContractRouterClient } from '@orpc/contract' +import type { JsonifiedClient } from '@orpc/openapi-client' +import type { APIRequestContext } from '@playwright/test' +import type { ConsoleClientContext } from './playwright-fetch' +import { consoleRouterContract } from '@dify/contracts/api/console/router.gen' +import { createORPCClient } from '@orpc/client' +import { RequestValidationPlugin, ResponseValidationPlugin } from '@orpc/contract/plugins' +import { OpenAPILink } from '@orpc/openapi-client/fetch' +import { apiURL } from '../../test-env' +import { createPlaywrightFetch } from './playwright-fetch' + +type ConsoleRequestContext = Pick + +export type ConsoleClient = JsonifiedClient< + ContractRouterClient +> + +export type CreateConsoleClientOptions = { + apiBaseURL?: string + requestContext: ConsoleRequestContext + requireCsrfToken?: boolean +} + +const getCsrfToken = async (requestContext: ConsoleRequestContext) => { + const state = await requestContext.storageState() + return state.cookies.find((cookie) => cookie.name.endsWith('csrf_token'))?.value +} + +export function createConsoleClient({ + apiBaseURL = apiURL, + requestContext, + requireCsrfToken = true, +}: CreateConsoleClientOptions): ConsoleClient { + const link = new OpenAPILink(consoleRouterContract, { + fetch: createPlaywrightFetch(requestContext), + headers: async () => { + const headers = new Headers({ Accept: 'application/json' }) + const csrfToken = await getCsrfToken(requestContext) + if (!csrfToken && requireCsrfToken) + throw new Error('The Console API client requires an authenticated CSRF token.') + if (csrfToken) headers.set('X-CSRF-Token', csrfToken) + return headers + }, + plugins: [ + new RequestValidationPlugin(consoleRouterContract), + new ResponseValidationPlugin(consoleRouterContract), + ], + url: new URL('/console/api/', apiBaseURL).toString(), + }) + + return createORPCClient(link) +} diff --git a/e2e/support/api/console-context.ts b/e2e/support/api/console-context.ts deleted file mode 100644 index 6ba5c4c33df..00000000000 --- a/e2e/support/api/console-context.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { APIResponse } from '@playwright/test' -import { readFile } from 'node:fs/promises' -import { request } from '@playwright/test' -import * as z from 'zod' -import { authStatePath } from '../../fixtures/auth' -import { apiURL } from '../../test-env' - -const zStorageState = z.object({ - cookies: z.array( - z.object({ - name: z.string(), - value: z.string(), - }), - ), -}) - -export async function createConsoleApiContext() { - const state = zStorageState.parse(JSON.parse(await readFile(authStatePath, 'utf8'))) - const csrfToken = state.cookies.find((cookie) => cookie.name.endsWith('csrf_token'))?.value - if (!csrfToken) throw new Error(`No CSRF token found in E2E auth state: ${authStatePath}`) - - return request.newContext({ - baseURL: apiURL, - extraHTTPHeaders: { 'X-CSRF-Token': csrfToken }, - storageState: authStatePath, - }) -} - -export async function expectApiResponseOK(response: APIResponse, action: string): Promise { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`) -} diff --git a/e2e/support/api/console-session.ts b/e2e/support/api/console-session.ts new file mode 100644 index 00000000000..ab7fc8790ac --- /dev/null +++ b/e2e/support/api/console-session.ts @@ -0,0 +1,16 @@ +import { request } from '@playwright/test' +import { authStatePath } from '../../fixtures/auth' +import { apiURL } from '../../test-env' +import { createConsoleClient } from './console-client' + +export async function createStandaloneConsoleSession() { + const requestContext = await request.newContext({ + baseURL: apiURL, + storageState: authStatePath, + }) + + return { + client: createConsoleClient({ requestContext }), + dispose: () => requestContext.dispose(), + } +} diff --git a/e2e/support/api/datasets.ts b/e2e/support/api/datasets.ts deleted file mode 100644 index 9a696a24d8b..00000000000 --- a/e2e/support/api/datasets.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createConsoleApiContext, expectApiResponseOK } from './console-context' - -export async function deleteTestDataset(datasetId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/datasets/${datasetId}`) - await expectApiResponseOK(response, `Delete dataset ${datasetId}`) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/marketplace-plugins.ts b/e2e/support/api/marketplace-plugins.ts deleted file mode 100644 index 1dea0c7d95c..00000000000 --- a/e2e/support/api/marketplace-plugins.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - GetWorkspacesCurrentPluginTasksByTaskIdResponse, - ParserLatest, - ParserPluginIdentifiers, - PostWorkspacesCurrentPluginInstallMarketplaceResponse, - PostWorkspacesCurrentPluginInstallPkgResponse, - PostWorkspacesCurrentPluginListInstallationsIdsResponse, - PostWorkspacesCurrentPluginListLatestVersionsResponse, - PostWorkspacesCurrentPluginUploadPkgResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' -import type { Buffer } from 'node:buffer' -import { - zGetWorkspacesCurrentPluginTasksByTaskIdResponse, - zPostWorkspacesCurrentPluginInstallMarketplaceResponse, - zPostWorkspacesCurrentPluginInstallPkgResponse, - zPostWorkspacesCurrentPluginListInstallationsIdsResponse, - zPostWorkspacesCurrentPluginListLatestVersionsResponse, - zPostWorkspacesCurrentPluginUploadPkgResponse, -} from '@dify/contracts/api/console/workspaces/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' - -export async function getLatestMarketplacePluginVersions( - pluginIds: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { plugin_ids: pluginIds } satisfies ParserLatest - const response = await ctx.post('/console/api/workspaces/current/plugin/list/latest-versions', { - data, - }) - await expectApiResponseOK(response, 'Resolve latest marketplace plugin versions') - return zPostWorkspacesCurrentPluginListLatestVersionsResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function getInstalledMarketplacePlugins( - pluginIds: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { plugin_ids: pluginIds } satisfies ParserLatest - const response = await ctx.post( - '/console/api/workspaces/current/plugin/list/installations/ids', - { data }, - ) - await expectApiResponseOK(response, 'List installed marketplace plugins') - return zPostWorkspacesCurrentPluginListInstallationsIdsResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function getMarketplacePluginInstallTask( - taskId: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/workspaces/current/plugin/tasks/${taskId}`) - await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) - return zGetWorkspacesCurrentPluginTasksByTaskIdResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function startMarketplacePluginInstall( - pluginUniqueIdentifiers: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - plugin_unique_identifiers: pluginUniqueIdentifiers, - } satisfies ParserPluginIdentifiers - const response = await ctx.post('/console/api/workspaces/current/plugin/install/marketplace', { - data, - }) - await expectApiResponseOK(response, 'Install marketplace plugins') - return zPostWorkspacesCurrentPluginInstallMarketplaceResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function uploadMarketplacePluginPackageFile( - pkg: Buffer, - fileName: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/upload/pkg', { - multipart: { - pkg: { - buffer: pkg, - mimeType: 'application/octet-stream', - name: fileName, - }, - }, - }) - await expectApiResponseOK(response, `Upload marketplace package ${fileName}`) - return zPostWorkspacesCurrentPluginUploadPkgResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function startUploadedPluginPackageInstall( - pluginUniqueIdentifiers: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - plugin_unique_identifiers: pluginUniqueIdentifiers, - } satisfies ParserPluginIdentifiers - const response = await ctx.post('/console/api/workspaces/current/plugin/install/pkg', { data }) - await expectApiResponseOK(response, 'Install uploaded plugin packages') - return zPostWorkspacesCurrentPluginInstallPkgResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/playwright-fetch.ts b/e2e/support/api/playwright-fetch.ts new file mode 100644 index 00000000000..baae3e01257 --- /dev/null +++ b/e2e/support/api/playwright-fetch.ts @@ -0,0 +1,61 @@ +import type { OpenAPILinkOptions } from '@orpc/openapi-client/fetch' +import type { APIRequestContext } from '@playwright/test' +import { Buffer } from 'node:buffer' + +export type ConsoleClientContext = { + timeoutMs?: number +} + +type PlaywrightRequestContext = Pick +type OpenAPIFetch = NonNullable['fetch']> + +const defaultRequestTimeoutMs = 30_000 +const bodylessResponseStatuses = new Set([204, 205, 304]) + +export function createPlaywrightFetch(requestContext: PlaywrightRequestContext): OpenAPIFetch { + return async (request, _init, options, path) => { + request.signal.throwIfAborted() + + const headers = Object.fromEntries(request.headers.entries()) + delete headers['content-length'] + + const data = request.body ? Buffer.from(await request.arrayBuffer()) : undefined + const apiResponse = await requestContext.fetch(request.url, { + ...(data === undefined ? {} : { data }), + failOnStatusCode: false, + headers, + maxRedirects: 0, + method: request.method, + timeout: options.context.timeoutMs ?? defaultRequestTimeoutMs, + }) + + try { + const status = apiResponse.status() + if (status >= 300 && status < 400) { + const location = apiResponse.headers().location + throw new Error( + `Console API ${path.join('.')} redirected with ${status}${location ? ` to ${location}` : ''}.`, + ) + } + + const responseHeaders = new Headers() + for (const { name, value } of apiResponse.headersArray()) responseHeaders.append(name, value) + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + responseHeaders.delete('transfer-encoding') + + const body = + request.method === 'HEAD' || bodylessResponseStatuses.has(status) + ? null + : Uint8Array.from(await apiResponse.body()) + + return new Response(body, { + headers: responseHeaders, + status, + statusText: apiResponse.statusText(), + }) + } finally { + await apiResponse.dispose() + } + } +} diff --git a/e2e/support/api/tools.ts b/e2e/support/api/tools.ts deleted file mode 100644 index 10db3d50713..00000000000 --- a/e2e/support/api/tools.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BuiltinToolCredentialDeletePayload } from '@dify/contracts/api/console/workspaces/types.gen' -import { zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse } from '@dify/contracts/api/console/workspaces/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' - -export async function deleteBuiltinToolCredential( - provider: string, - credentialId: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { credential_id: credentialId } satisfies BuiltinToolCredentialDeletePayload - const response = await ctx.post( - `/console/api/workspaces/current/tool-provider/builtin/${provider}/delete`, - { data }, - ) - await expectApiResponseOK( - response, - `Delete built-in tool credential ${credentialId} for ${provider}`, - ) - zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/web-apps.ts b/e2e/support/api/web-apps.ts index c1f557285dd..fe97aee87ef 100644 --- a/e2e/support/api/web-apps.ts +++ b/e2e/support/api/web-apps.ts @@ -1,10 +1,4 @@ -import type { - AppDetailWithSite, - AppSiteStatusPayload, - GetAppsByAppIdResponse, -} from '@dify/contracts/api/console/apps/types.gen' -import { zGetAppsByAppIdResponse } from '@dify/contracts/api/console/apps/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { if (!site?.app_base_url || !site.access_token) @@ -18,30 +12,3 @@ export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { return `${site.app_base_url}/${webAppMode}/${site.access_token}` } - -export async function getAppSiteDetail(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}`) - await expectApiResponseOK(response, `Get app site detail for ${appId}`) - return zGetAppsByAppIdResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function enableAppSiteAndGetURL(appId: string): Promise { - await setAppSiteEnabled(appId, true) - return getAppSiteURL(await getAppSiteDetail(appId)) -} - -export async function setAppSiteEnabled(appId: string, enabled: boolean): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { enable_site: enabled } satisfies AppSiteStatusPayload - const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, { data }) - await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/workflows.ts b/e2e/support/api/workflows.ts index cc826c508fb..00ee6544d4e 100644 --- a/e2e/support/api/workflows.ts +++ b/e2e/support/api/workflows.ts @@ -1,115 +1,70 @@ -import type { - GetAppsByAppIdWorkflowsDraftResponse, - PublishWorkflowPayload, - SyncDraftWorkflowPayload, -} from '@dify/contracts/api/console/apps/types.gen' -import { - zGetAppsByAppIdWorkflowsDraftResponse, - zPostAppsByAppIdWorkflowsDraftResponse, - zPostAppsByAppIdWorkflowsPublishResponse, -} from '@dify/contracts/api/console/apps/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' +import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' +import type { ConsoleClient } from './console-client' -export async function getWorkflowDraft( +export async function syncMinimalWorkflowDraft( + client: ConsoleClient, appId: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}/workflows/draft`) - await expectApiResponseOK(response, `Get workflow draft for ${appId}`) - return zGetAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } +): Promise { + const body = { + graph: { + nodes: [ + { + id: '1', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: '1', type: 'start', title: 'Start', variables: [] }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) } -export async function syncMinimalWorkflowDraft(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - graph: { - nodes: [ - { - id: '1', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: '1', type: 'start', title: 'Start', variables: [] }, - }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - } satisfies SyncDraftWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) - await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`) - zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function syncRunnableWorkflowDraft(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { +export async function syncRunnableWorkflowDraft( + client: ConsoleClient, + appId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'end', + type: 'custom', + position: { x: 480, y: 282 }, + data: { id: 'end', - type: 'custom', - position: { x: 480, y: 282 }, - data: { - id: 'end', - type: 'end', - title: 'End', - outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], - }, + type: 'end', + title: 'End', + outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], }, - ], - edges: [ - { - id: 'start-end', - type: 'custom', - source: 'start', - target: 'end', - sourceHandle: 'source', - targetHandle: 'target', - }, - ], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - } satisfies SyncDraftWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) - await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`) - zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function publishWorkflowApp(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - marked_name: '', - marked_comment: '', - } satisfies PublishWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { data }) - await expectApiResponseOK(response, `Publish workflow app ${appId}`) - zPostAppsByAppIdWorkflowsPublishResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + }, + ], + edges: [ + { + id: 'start-end', + type: 'custom', + source: 'start', + target: 'end', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) } diff --git a/e2e/support/marketplace-plugins.ts b/e2e/support/marketplace-plugins.ts index 4932bf14eb6..cb8b837ef11 100644 --- a/e2e/support/marketplace-plugins.ts +++ b/e2e/support/marketplace-plugins.ts @@ -1,14 +1,8 @@ import type { PluginInstallTask } from '@dify/contracts/api/console/workspaces/types.gen' +import type { ConsoleClient } from './api/console-client' import type { SeedContext, SeedResult } from './seed' import { Buffer } from 'node:buffer' -import { - getInstalledMarketplacePlugins, - getLatestMarketplacePluginVersions, - getMarketplacePluginInstallTask, - startMarketplacePluginInstall, - startUploadedPluginPackageInstall, - uploadMarketplacePluginPackageFile, -} from './api/marketplace-plugins' +import { ORPCError } from '@orpc/client' import { sleep } from './process' import { blocked, created, skipped, verified } from './seed' @@ -34,10 +28,12 @@ const unique = (values: string[]) => Array.from(new Set(values)) const getPluginId = (pluginUniqueIdentifier: string) => pluginUniqueIdentifier.split(':')[0]?.trim() || pluginUniqueIdentifier.trim() -const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { +const resolveLatestPluginIdentifiers = async (client: ConsoleClient, pluginIds: string[]) => { if (pluginIds.length === 0) return { identifiers: [] as string[], missing: [] as string[] } - const body = await getLatestMarketplacePluginVersions(pluginIds) + const body = await client.workspaces.current.plugin.list.latestVersions.post({ + body: { plugin_ids: pluginIds }, + }) const identifiers: string[] = [] const missing: string[] = [] @@ -50,18 +46,30 @@ const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { return { identifiers, missing } } -const listInstalledPlugins = async (pluginIds: string[]) => { +const listInstalledPlugins = async (client: ConsoleClient, pluginIds: string[]) => { if (pluginIds.length === 0) return [] - return (await getInstalledMarketplacePlugins(pluginIds)).plugins + return ( + await client.workspaces.current.plugin.list.installations.ids.post({ + body: { plugin_ids: pluginIds }, + }) + ).plugins } -const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => { +const waitForPluginInstallTask = async ( + client: ConsoleClient, + taskId: string, + timeoutMs = 300_000, +) => { const deadline = Date.now() + timeoutMs let lastTask: PluginInstallTask | undefined while (Date.now() < deadline) { - lastTask = (await getMarketplacePluginInstallTask(taskId)).task + lastTask = ( + await client.workspaces.current.plugin.tasks.byTaskId.get({ + params: { task_id: taskId }, + }) + ).task if (lastTask?.status === terminalSuccessTaskStatus) return { ok: true as const, task: lastTask } @@ -90,10 +98,6 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => return { ok: false as const, reason: `Plugin install task did not finish within ${timeoutMs}ms.` } } -const installMarketplacePlugins = async (pluginUniqueIdentifiers: string[]) => { - return startMarketplacePluginInstall(pluginUniqueIdentifiers) -} - const getMarketplaceDownloadUrl = (pluginUniqueIdentifier: string) => { const url = new URL( '/api/v1/plugins/download', @@ -114,25 +118,49 @@ const downloadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) return Buffer.from(await response.arrayBuffer()) } -const uploadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => { +const uploadMarketplacePluginPackage = async ( + client: ConsoleClient, + pluginUniqueIdentifier: string, +) => { const pkg = await downloadMarketplacePluginPackage(pluginUniqueIdentifier) const fileName = `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg` - return (await uploadMarketplacePluginPackageFile(pkg, fileName)).unique_identifier + const response = await client.workspaces.current.plugin.upload.pkg.post({ + body: { + pkg: new File([Uint8Array.from(pkg)], fileName, { type: 'application/octet-stream' }), + }, + }) + return response.unique_identifier } -const installLocalPluginPackages = async (pluginUniqueIdentifiers: string[]) => { - return startUploadedPluginPackageInstall(pluginUniqueIdentifiers) +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const getMarketplaceInstallErrorText = (error: unknown) => { + const messages = [error instanceof Error ? error.message : String(error)] + + if (error instanceof ORPCError && isRecord(error.data)) { + const body = error.data.body + if (isRecord(body) && typeof body.message === 'string') messages.push(body.message) + } + + return messages.join('\n') } -const shouldFallbackToLocalPackageInstall = (error: string) => - error.includes('/plugins/download') || error.includes('Reached maximum retries') +const shouldFallbackToLocalPackageInstall = (error: unknown) => { + const message = getMarketplaceInstallErrorText(error) + return message.includes('/plugins/download') || message.includes('Reached maximum retries') +} -const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: string[]) => { +const installMarketplacePluginsWithFallback = async ( + client: ConsoleClient, + pluginUniqueIdentifiers: string[], +) => { try { - return await installMarketplacePlugins(pluginUniqueIdentifiers) + return await client.workspaces.current.plugin.install.marketplace.post({ + body: { plugin_unique_identifiers: pluginUniqueIdentifiers }, + }) } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (!shouldFallbackToLocalPackageInstall(message)) throw error + if (!shouldFallbackToLocalPackageInstall(error)) throw error console.warn( '[seed] marketplace install download failed in API process; falling back to local package upload.', @@ -140,10 +168,12 @@ const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: st const uploadedPluginUniqueIdentifiers: string[] = [] for (const pluginUniqueIdentifier of pluginUniqueIdentifiers) uploadedPluginUniqueIdentifiers.push( - await uploadMarketplacePluginPackage(pluginUniqueIdentifier), + await uploadMarketplacePluginPackage(client, pluginUniqueIdentifier), ) - return await installLocalPluginPackages(uploadedPluginUniqueIdentifiers) + return await client.workspaces.current.plugin.install.pkg.post({ + body: { plugin_unique_identifiers: uploadedPluginUniqueIdentifiers }, + }) } } @@ -152,12 +182,13 @@ export const bootstrapMarketplacePlugins = async ( config: MarketplacePluginBootstrapConfig, ): Promise => { const requestedPluginIds = parseListEnv(config.pluginIdsEnv) + const client = context.consoleClient const pluginIds = unique( requestedPluginIds.length > 0 ? requestedPluginIds : config.defaultPluginIds, ) if (pluginIds.length > 0) { - const installedPlugins = await listInstalledPlugins(pluginIds) + const installedPlugins = await listInstalledPlugins(client, pluginIds) const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) if (pluginIds.every((pluginId) => installedPluginIds.has(pluginId))) { return verified(config.title, { @@ -168,7 +199,7 @@ export const bootstrapMarketplacePlugins = async ( } } - const resolved = await resolveLatestPluginIdentifiers(pluginIds) + const resolved = await resolveLatestPluginIdentifiers(client, pluginIds) if (resolved.missing.length > 0) { return blocked( @@ -183,7 +214,7 @@ export const bootstrapMarketplacePlugins = async ( if (requiredPluginUniqueIdentifiers.length === 0) return skipped(config.title, 'No marketplace plugins were requested.') - const installedPlugins = await listInstalledPlugins(requiredPluginIds) + const installedPlugins = await listInstalledPlugins(client, requiredPluginIds) const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) const missingPluginUniqueIdentifiers = requiredPluginUniqueIdentifiers.filter( (identifier) => !installedPluginIds.has(getPluginId(identifier)), @@ -204,9 +235,10 @@ export const bootstrapMarketplacePlugins = async ( } const startedTask = await installMarketplacePluginsWithFallback( + client, missingPluginUniqueIdentifiers, ).catch((error) => { - return { error: error instanceof Error ? error.message : String(error) } + return { error: getMarketplaceInstallErrorText(error) } }) if ('error' in startedTask) return blocked(config.title, startedTask.error) @@ -215,7 +247,7 @@ export const bootstrapMarketplacePlugins = async ( const taskId = startedTask.task_id || startedTask.task?.id if (!taskId) return blocked(config.title, 'Marketplace plugin install did not return a task id.') - const taskResult = await waitForPluginInstallTask(taskId) + const taskResult = await waitForPluginInstallTask(client, taskId) if (!taskResult.ok) return blocked(config.title, taskResult.reason) return created(config.title, resource) diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts index eddac03dd46..e0ec106de0d 100644 --- a/e2e/support/seed.ts +++ b/e2e/support/seed.ts @@ -1,3 +1,4 @@ +import type { ConsoleClient } from './api/console-client' import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { e2eDir } from '../scripts/common' @@ -18,6 +19,7 @@ export type SeedResult = { } export type SeedContext = { + consoleClient: ConsoleClient dryRun: boolean resources: Map } diff --git a/e2e/tests/console-client.test.ts b/e2e/tests/console-client.test.ts new file mode 100644 index 00000000000..40078b08554 --- /dev/null +++ b/e2e/tests/console-client.test.ts @@ -0,0 +1,208 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test' +import { Buffer } from 'node:buffer' +import { describe, expect, it, vi } from 'vitest' +import { createConsoleClient } from '../support/api/console-client' +import { createPlaywrightFetch } from '../support/api/playwright-fetch' + +const createApiResponse = ({ + body = '', + headers = { 'content-type': 'application/json' }, + status = 200, + statusText = 'OK', + url = 'http://api.test/console/api/apps/app-1', +}: { + body?: string + headers?: Record + status?: number + statusText?: string + url?: string +} = {}): APIResponse => { + const bodyBuffer = Buffer.from(body) + + return { + body: async () => bodyBuffer, + dispose: async () => {}, + headers: () => headers, + headersArray: () => Object.entries(headers).map(([name, value]) => ({ name, value })), + json: async () => JSON.parse(body), + ok: () => status >= 200 && status < 300, + securityDetails: async () => null, + serverAddr: async () => null, + status: () => status, + statusText: () => statusText, + text: async () => body, + url: () => url, + [Symbol.asyncDispose]: async () => {}, + } +} + +const createRequestContext = (response: APIResponse, csrfToken = 'csrf-token') => { + const fetch = vi.fn(async () => response) + const context = { + fetch, + storageState: vi.fn(async () => ({ + cookies: [ + { + domain: 'api.test', + expires: -1, + httpOnly: false, + name: 'csrf_token', + path: '/', + sameSite: 'Lax', + secure: false, + value: csrfToken, + }, + ], + origins: [], + })), + } satisfies Pick + + return { context, fetch } +} + +const callPlaywrightFetch = (requestContext: Pick, request: Request) => + createPlaywrightFetch(requestContext)(request, {}, { context: {} }, ['test'], undefined) + +describe('createPlaywrightFetch', () => { + it('forwards the Fetch request without following redirects and returns a standard Response', async () => { + const apiResponse = createApiResponse({ + body: '{"ok":true}', + status: 201, + statusText: 'Created', + }) + const { context, fetch } = createRequestContext(apiResponse) + const response = await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/apps', { + body: '{"name":"E2E App"}', + headers: { 'content-type': 'application/json', 'x-test': 'value' }, + method: 'POST', + }), + ) + + expect(fetch).toHaveBeenCalledWith( + 'http://api.test/console/api/apps', + expect.objectContaining({ + data: Buffer.from('{"name":"E2E App"}'), + failOnStatusCode: false, + headers: expect.objectContaining({ 'content-type': 'application/json', 'x-test': 'value' }), + maxRedirects: 0, + method: 'POST', + }), + ) + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ ok: true }) + }) + + it('represents a 204 response without an invalid response body', async () => { + const { context } = createRequestContext(createApiResponse({ body: '', status: 204 })) + + const response = await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/apps/app-1', { method: 'DELETE' }), + ) + + expect(response.status).toBe(204) + await expect(response.text()).resolves.toBe('') + }) + + it('forwards generated multipart bodies as raw bytes with their boundary', async () => { + const { context, fetch } = createRequestContext(createApiResponse({ body: '{"ok":true}' })) + const formData = new FormData() + formData.append('pkg', new File(['plugin-package'], 'plugin.difypkg')) + + await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/workspaces/current/plugin/upload/pkg', { + body: formData, + method: 'POST', + }), + ) + + const options = fetch.mock.calls[0]?.[1] + expect(options?.headers).toEqual( + expect.objectContaining({ 'content-type': expect.stringContaining('multipart/form-data') }), + ) + expect(Buffer.isBuffer(options?.data)).toBe(true) + expect((options?.data as Buffer).toString()).toContain('plugin.difypkg') + expect((options?.data as Buffer).toString()).toContain('plugin-package') + }) + + it('rejects redirects as an authentication or routing infrastructure failure', async () => { + const dispose = vi.fn(async () => {}) + const { context } = createRequestContext({ + ...createApiResponse({ + body: '', + headers: { location: 'http://web.test/signin' }, + status: 302, + statusText: 'Found', + }), + dispose, + }) + + await expect( + callPlaywrightFetch(context, new Request('http://api.test/console/api/apps')), + ).rejects.toThrow('redirected with 302') + expect(dispose).toHaveBeenCalledOnce() + }) +}) + +describe('createConsoleClient', () => { + it('validates generated request inputs before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse()) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.get({ + params: { app_id: 1 as unknown as string }, + }), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects invalid generated multipart values before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse()) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.workspaces.current.plugin.upload.pkg.post({ + body: { pkg: 1 as unknown as File }, + }), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) + + it('validates server responses against the generated response schema', async () => { + const { context } = createRequestContext(createApiResponse({ body: '{"id":1}' })) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.get({ params: { app_id: '00000000-0000-4000-8000-000000000001' } }), + ).rejects.toThrow() + }) + + it('adds the current CSRF token and accepts generated 204 responses', async () => { + const { context, fetch } = createRequestContext(createApiResponse({ body: '', status: 204 })) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.delete({ + params: { app_id: '00000000-0000-4000-8000-000000000001' }, + }), + ).resolves.toBeUndefined() + const requestHeaders = fetch.mock.calls[0]?.[1]?.headers + expect(requestHeaders).toEqual(expect.objectContaining({ 'x-csrf-token': 'csrf-token' })) + }) + + it('rejects authenticated calls without a CSRF token before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse(), '') + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.delete({ + params: { app_id: '00000000-0000-4000-8000-000000000001' }, + }), + ).rejects.toThrow('requires an authenticated CSRF token') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/e2e/tests/marketplace-plugins.test.ts b/e2e/tests/marketplace-plugins.test.ts new file mode 100644 index 00000000000..dda4008adb7 --- /dev/null +++ b/e2e/tests/marketplace-plugins.test.ts @@ -0,0 +1,108 @@ +import type { ConsoleClient } from '../support/api/console-client' +import { ORPCError } from '@orpc/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bootstrapMarketplacePlugins } from '../support/marketplace-plugins' + +const createMarketplaceConsoleClient = (installError: unknown) => { + const installMarketplace = vi.fn().mockRejectedValue(installError) + const uploadPackage = vi.fn().mockResolvedValue({ + unique_identifier: 'langgenius/test:1.0.0@package', + }) + const installPackage = vi.fn().mockResolvedValue({ all_installed: true }) + const consoleClient = { + workspaces: { + current: { + plugin: { + install: { + marketplace: { post: installMarketplace }, + pkg: { post: installPackage }, + }, + list: { + installations: { + ids: { post: vi.fn().mockResolvedValue({ plugins: [] }) }, + }, + latestVersions: { + post: vi.fn().mockResolvedValue({ + versions: { + 'langgenius/test': { + unique_identifier: 'langgenius/test:1.0.0@marketplace', + }, + }, + }), + }, + }, + upload: { pkg: { post: uploadPackage } }, + }, + }, + }, + } as unknown as ConsoleClient + + return { consoleClient, installPackage, uploadPackage } +} + +const bootstrapTestPlugin = (consoleClient: ConsoleClient) => + bootstrapMarketplacePlugins( + { consoleClient, dryRun: false, resources: new Map() }, + { + defaultPluginIds: ['langgenius/test'], + pluginIdsEnv: 'E2E_TEST_MARKETPLACE_PLUGIN_IDS', + title: 'Test marketplace plugin', + }, + ) + +describe('bootstrapMarketplacePlugins', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('uses generated package upload when the API process cannot download from Marketplace', async () => { + vi.stubEnv('E2E_TEST_MARKETPLACE_PLUGIN_IDS', '') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('plugin-package')) + const { consoleClient, installPackage, uploadPackage } = createMarketplaceConsoleClient( + new ORPCError('INTERNAL_SERVER_ERROR', { + data: { + body: { + message: + 'Reached maximum retries (3) for URL https://marketplace.test/plugins/download', + }, + }, + status: 500, + }), + ) + const result = await bootstrapTestPlugin(consoleClient) + + expect(result.status).toBe('verified') + expect(uploadPackage).toHaveBeenCalledWith({ + body: { pkg: expect.any(File) }, + }) + expect(installPackage).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: ['langgenius/test:1.0.0@package'] }, + }) + }) + + it('does not hide unrelated generated client failures behind package upload', async () => { + vi.stubEnv('E2E_TEST_MARKETPLACE_PLUGIN_IDS', '') + const marketplaceFetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('plugin-package')) + const { consoleClient, installPackage, uploadPackage } = createMarketplaceConsoleClient( + new ORPCError('INTERNAL_SERVER_ERROR', { + data: { body: { message: 'Database unavailable' } }, + status: 500, + }), + ) + + const result = await bootstrapTestPlugin(consoleClient) + + expect(result).toEqual( + expect.objectContaining({ + reason: expect.stringContaining('Database unavailable'), + status: 'blocked', + }), + ) + expect(marketplaceFetch).not.toHaveBeenCalled() + expect(uploadPackage).not.toHaveBeenCalled() + expect(installPackage).not.toHaveBeenCalled() + }) +}) diff --git a/packages/contracts/binary-zod.test.ts b/packages/contracts/binary-zod.test.ts new file mode 100644 index 00000000000..35a6a96b290 --- /dev/null +++ b/packages/contracts/binary-zod.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { zPostFilesUploadBody } from './generated/api/console/files/zod.gen' +import { zPostWorkspacesCurrentPluginUploadPkgBody } from './generated/api/console/workspaces/zod.gen' + +describe('generated binary schemas', () => { + it.each([ + ['file upload', zPostFilesUploadBody, 'file'], + ['plugin package upload', zPostWorkspacesCurrentPluginUploadPkgBody, 'pkg'], + ] as const)('validates %s values at runtime', (_, schema, field) => { + const file = new File(['test'], 'test.txt', { type: 'text/plain' }) + + expect(schema.safeParse({ [field]: file }).success).toBe(true) + expect(schema.safeParse({ [field]: 123 }).success).toBe(false) + }) +}) diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 6dc95e1cd7f..2a5e0dcb3b2 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -1234,14 +1234,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -1620,14 +1620,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -2268,6 +2268,10 @@ export type GetAgentByAgentIdBuildDraftData = { url: '/agent/{agent_id}/build-draft' } +export type GetAgentByAgentIdBuildDraftErrors = { + 404: unknown +} + export type GetAgentByAgentIdBuildDraftResponses = { 200: AgentBuildDraftResponse } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 4e60c853ab8..085f6551390 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -1732,10 +1732,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -2201,10 +2201,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -2887,7 +2887,7 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void() export const zPostAgentByAgentIdAudioToTextBody = z.object({ draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdAudioToTextPath = z.object({ @@ -3137,7 +3137,7 @@ export const zGetAgentByAgentIdConfigSkillsQuery = z.object({ export const zGetAgentByAgentIdConfigSkillsResponse = zAgentConfigSkillListResponse export const zPostAgentByAgentIdConfigSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdConfigSkillsUploadPath = z.object({ @@ -3511,7 +3511,7 @@ export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse export const zPostAgentByAgentIdSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdSkillsUploadPath = z.object({ diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 5be8467e1d2..abf5b16538a 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2070,14 +2070,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -2504,14 +2504,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index bba24bfa40a..a41672798e6 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2938,10 +2938,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -3668,10 +3668,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -4721,7 +4721,7 @@ export const zGetAppsByAppIdAgentConfigSkillsQuery = z.object({ export const zGetAppsByAppIdAgentConfigSkillsResponse = zAgentConfigSkillListResponse export const zPostAppsByAppIdAgentConfigSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAgentConfigSkillsUploadPath = z.object({ @@ -4952,7 +4952,7 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ @@ -5163,7 +5163,7 @@ export const zPostAppsByAppIdApiEnablePath = z.object({ export const zPostAppsByAppIdApiEnableResponse = zAppDetail export const zPostAppsByAppIdAudioToTextBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAudioToTextPath = z.object({ diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index 34fe6d2aa3d..d3d35b401a3 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -64,7 +64,7 @@ export const zGetFilesSupportTypeResponse = zAllowedExtensionsResponse export const zGetFilesUploadResponse = zUploadConfig export const zPostFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), source: z.enum(['datasets']).optional(), }) diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index e52940bb8a4..f0acbe06c0e 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -144,7 +144,9 @@ export const zTextToAudioPayload = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * WorkflowRunPayload diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index 28cb3250d68..d5b6a62cfc4 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -436,14 +436,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -652,14 +652,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 491b9acdada..4474c465df9 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -660,10 +660,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -1214,10 +1214,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 14d8bed2c92..9f6536e5190 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -115,7 +115,9 @@ export const zTextToSpeechRequest = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * WorkflowRunRequest @@ -457,7 +459,7 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetListResponse export const zPostTrialAppsByAppIdFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), source: z.enum(['datasets']).optional(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index d06495b1e31..730d89367a7 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -307,6 +307,7 @@ import { zPostWorkspacesCurrentPluginUploadBundleResponse, zPostWorkspacesCurrentPluginUploadGithubBody, zPostWorkspacesCurrentPluginUploadGithubResponse, + zPostWorkspacesCurrentPluginUploadPkgBody, zPostWorkspacesCurrentPluginUploadPkgResponse, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse, @@ -2055,6 +2056,7 @@ export const post43 = oc path: '/workspaces/current/plugin/upload/pkg', tags: ['console'], }) + .input(z.object({ body: zPostWorkspacesCurrentPluginUploadPkgBody })) .output(zPostWorkspacesCurrentPluginUploadPkgResponse) export const pkg3 = { diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 1600000227c..3a3b4ad620c 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -3865,7 +3865,9 @@ export type PostWorkspacesCurrentPluginUploadGithubResponse = PostWorkspacesCurrentPluginUploadGithubResponses[keyof PostWorkspacesCurrentPluginUploadGithubResponses] export type PostWorkspacesCurrentPluginUploadPkgData = { - body?: never + body: { + pkg: Blob | File + } path?: never query?: never url: '/workspaces/current/plugin/upload/pkg' diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 0fd5ae091f6..1f1530897fa 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -245,7 +245,9 @@ export const zWorkspacePermissionResponse = z.object({ /** * BinaryFileResponse */ -export const zBinaryFileResponse = z.custom() +export const zBinaryFileResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * PluginAutoUpgradeChangeResponse @@ -4223,6 +4225,10 @@ export const zPostWorkspacesCurrentPluginUploadGithubBody = zParserGithubUpload */ export const zPostWorkspacesCurrentPluginUploadGithubResponse = zPluginDecodeResponse +export const zPostWorkspacesCurrentPluginUploadPkgBody = z.object({ + pkg: z.custom((value) => value instanceof Blob || value instanceof File), +}) + /** * Success */ @@ -5248,7 +5254,7 @@ export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload export const zPostWorkspacesCustomConfigResponse = zWorkspaceTenantResultResponse export const zPostWorkspacesCustomConfigWebappLogoUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) /** diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 2ec5f11174b..78fcc65cdad 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -112,7 +112,9 @@ export const zAppMetaResponse = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * AudioTranscriptResponse @@ -124,7 +126,9 @@ export const zAudioTranscriptResponse = z.object({ /** * BinaryFileResponse */ -export const zBinaryFileResponse = z.custom() +export const zBinaryFileResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * ButtonStyle @@ -2452,7 +2456,7 @@ export const zPutAppsAnnotationsByAnnotationIdPath = z.object({ export const zPutAppsAnnotationsByAnnotationIdResponse = zAnnotation export const zPostAudioToTextBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), user: z.string().optional(), }) @@ -2591,7 +2595,7 @@ export const zPostDatasetsBody = zDatasetCreatePayload export const zPostDatasetsResponse = zDatasetDetailResponse export const zPostDatasetsPipelineFileUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) /** @@ -2670,7 +2674,7 @@ export const zPatchDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMember export const zPostDatasetsByDatasetIdDocumentCreateByFileBody = z.object({ data: z.string().optional(), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostDatasetsByDatasetIdDocumentCreateByFilePath = z.object({ @@ -2695,7 +2699,7 @@ export const zPostDatasetsByDatasetIdDocumentCreateByTextResponse = zDocumentAnd export const zPostDatasetsByDatasetIdDocumentCreateByFile2Body = z.object({ data: z.string().optional(), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostDatasetsByDatasetIdDocumentCreateByFile2Path = z.object({ @@ -2745,7 +2749,9 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ /** * ZIP archive containing the requested documents. */ -export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom() +export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData @@ -2807,7 +2813,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentDet export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdBody = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ @@ -2967,7 +2973,7 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileBody = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFilePath = z.object({ @@ -2996,7 +3002,7 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse = export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Body = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Path = z.object({ @@ -3167,7 +3173,7 @@ export const zGetEndUsersByEndUserIdPath = z.object({ export const zGetEndUsersByEndUserIdResponse = zEndUserDetail export const zPostFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), user: z.string().optional(), }) @@ -3188,7 +3194,9 @@ export const zGetFilesByFileIdPreviewQuery = z.object({ /** * Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If `as_attachment` is `true`, the file is returned as a download with `Content-Disposition: attachment`. */ -export const zGetFilesByFileIdPreviewResponse = z.custom() +export const zGetFilesByFileIdPreviewResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), @@ -3271,7 +3279,9 @@ export const zPostTextToAudioBody = zTextToAudioPayloadWithUser /** * Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly. */ -export const zPostTextToAudioResponse = z.custom() +export const zPostTextToAudioResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zGetWorkflowByTaskIdEventsPath = z.object({ task_id: z.string(), diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 7d362f856d3..d1251df3657 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -3,6 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { $, defineConfig } from '@hey-api/openapi-ts' +import ts from 'typescript' type JsonObject = Record @@ -497,7 +498,15 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ if (ctx.schema.format === 'binary') return $(ctx.symbols.z) .attr('custom') - .call() + .call( + $.func((predicate) => { + const value = $.id('value') + const isBlob = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('Blob')) + const isFile = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('File')) + predicate.param('value') + predicate.do($.return($.binary(isBlob, '||', isFile))) + }), + ) .generic($.type.or($.type('Blob'), $.type('File'))) if (ctx.schema.pattern === pydanticDecimalStringPattern) { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 79f6c4e23a9..4f900c881e5 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -20,7 +20,7 @@ "scripts": { "gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", - "test": "vp test openapi-yaml.test.ts", + "test": "vp test", "type-check": "tsc" }, "dependencies": { diff --git a/packages/contracts/sandbox-contract.smoke.test.ts b/packages/contracts/sandbox-contract.smoke.test.ts index 84e721f14a7..c1e20b62905 100644 --- a/packages/contracts/sandbox-contract.smoke.test.ts +++ b/packages/contracts/sandbox-contract.smoke.test.ts @@ -1,26 +1,14 @@ -import assert from 'node:assert/strict' -import { registerHooks } from 'node:module' -import { dirname, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { sandbox as agentSandbox } from './generated/api/console/agent/orpc.gen' +import { sandbox as appSandbox } from './generated/api/console/apps/orpc.gen' -const thisDir = dirname(fileURLToPath(import.meta.url)) -const sourcePath = resolve(thisDir, './generated/api/console/apps/orpc.gen.ts') - -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === './zod.gen' || specifier.endsWith('/zod.gen')) - return nextResolve(`${specifier}.ts`, context) - - return nextResolve(specifier, context) - }, +describe('generated sandbox contracts', () => { + it.each([ + ['Agent sandbox', agentSandbox], + ['App sandbox', appSandbox], + ])('exposes the %s file operations', (_, sandbox) => { + expect(sandbox.files.get).toBeDefined() + expect(sandbox.files.read.get).toBeDefined() + expect(sandbox.files.upload.post).toBeDefined() + }) }) - -const { agentSandbox, sandbox } = await import(pathToFileURL(sourcePath).href) - -assert.ok(agentSandbox.files.get) -assert.ok(agentSandbox.files.read.get) -assert.ok(agentSandbox.files.upload.post) - -assert.ok(sandbox.files.get) -assert.ok(sandbox.files.read.get) -assert.ok(sandbox.files.upload.post) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6fb00fd6ac..543627cf50d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,15 @@ importers: '@dify/tsconfig': specifier: workspace:* version: link:../packages/tsconfig + '@orpc/client': + specifier: 'catalog:' + version: 1.14.8 + '@orpc/contract': + specifier: 'catalog:' + version: 1.14.8 + '@orpc/openapi-client': + specifier: 'catalog:' + version: 1.14.8 '@playwright/test': specifier: 'catalog:' version: 1.61.1 @@ -6038,9 +6047,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -9280,7 +9286,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.1.0': dependencies: - postcss: 8.5.17 + postcss: 8.5.19 '@amplitude/rrweb-types@2.0.0-alpha.40': {} @@ -13582,8 +13588,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} - es-module-lexer@2.3.1: {} es-toolkit@1.49.0: {} @@ -14103,6 +14107,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fflate@0.4.8: {} fflate@0.7.4: {} @@ -14234,7 +14242,7 @@ snapshots: buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -16825,8 +16833,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -17373,7 +17381,7 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 @@ -17404,7 +17412,7 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 @@ -17435,7 +17443,7 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 diff --git a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx index 0ca184ebe46..094518c0726 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react' +import type { Plugin } from '@/app/components/plugins/types' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' @@ -23,6 +24,16 @@ vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: vi.fn(), })) +vi.mock('@/app/components/workflow/block-selector/marketplace-plugin/list', () => ({ + default: ({ list }: { list: Plugin[] }) => ( +
+ {list.map((plugin) => ( +
{plugin.label.en_US}
+ ))} +
+ ), +})) + vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({ useMCPToolAvailability: () => ({ allowed: true, @@ -49,8 +60,20 @@ const mockUseTheme = vi.mocked(useTheme) const render = (ui: ReactElement, enableMarketplace = false) => renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: enableMarketplace } }) -const createMarketplacePluginsMock = () => - ({ data: undefined }) as ReturnType +const createMarketplacePluginsMock = ( + overrides: Partial> = {}, +) => + ({ data: undefined, isFetching: false, ...overrides }) as ReturnType + +const createMarketplaceData = (plugins: Plugin[]) => ({ + pages: [{ plugins, total: plugins.length, page: 1, page_size: 40 }], + pageParams: [1], +}) + +const marketplaceTool = { + plugin_id: 'marketplace-tool', + label: { en_US: 'Marketplace Tool' }, +} as Plugin describe('ToolBrowser', () => { beforeEach(() => { @@ -223,7 +246,13 @@ describe('ToolBrowser', () => { expect(screen.queryByText('Other Toolkit')).not.toBeInTheDocument() }) - it('shows the empty state when no tool matches the current filter', async () => { + it('shows the empty state and request action when local and marketplace tools do not match', async () => { + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ + data: params ? createMarketplaceData([]) : undefined, + }), + ) + render( { workflowTools={[]} mcpTools={[]} />, + true, ) await waitFor(() => { expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument() }) + expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute( + 'href', + 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml', + ) + }) + + it('keeps matching local tools visible while marketplace results are loading', async () => { + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ isFetching: params !== undefined }), + ) + + render( + , + true, + ) + + await waitFor(() => { + expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({ + query: 'local', + tags: [], + category: PluginCategoryEnum.tool, + }) + }) + expect(screen.getByText('Local Toolkit')).toBeInTheDocument() + expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument() + }) + + it('renders a marketplace result instead of the empty state', async () => { + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ + data: params ? createMarketplaceData([marketplaceTool]) : undefined, + }), + ) + + render( + , + true, + ) + + expect(await screen.findByText('Marketplace Tool')).toBeInTheDocument() + expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument() }) it('debounces marketplace requests across search and tag changes', async () => { From 03fad2e04195a48ff86a48e4ddde1dac1ca60b24 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:54:37 +0800 Subject: [PATCH 32/63] chore: Update permission tip across i18n locales (#39406) --- web/i18n/ar-TN/permission.json | 2 +- web/i18n/de-DE/permission.json | 2 +- web/i18n/en-US/permission.json | 2 +- web/i18n/es-ES/permission.json | 2 +- web/i18n/fa-IR/permission.json | 2 +- web/i18n/fr-FR/permission.json | 2 +- web/i18n/hi-IN/permission.json | 2 +- web/i18n/id-ID/permission.json | 2 +- web/i18n/it-IT/permission.json | 2 +- web/i18n/ja-JP/permission.json | 2 +- web/i18n/ko-KR/permission.json | 2 +- web/i18n/nl-NL/permission.json | 2 +- web/i18n/pl-PL/permission.json | 2 +- web/i18n/pt-BR/permission.json | 2 +- web/i18n/ro-RO/permission.json | 2 +- web/i18n/ru-RU/permission.json | 2 +- web/i18n/sl-SI/permission.json | 2 +- web/i18n/th-TH/permission.json | 2 +- web/i18n/tr-TR/permission.json | 2 +- web/i18n/uk-UA/permission.json | 2 +- web/i18n/vi-VN/permission.json | 2 +- web/i18n/zh-Hans/permission.json | 2 +- web/i18n/zh-Hant/permission.json | 2 +- 23 files changed, 23 insertions(+), 23 deletions(-) diff --git a/web/i18n/ar-TN/permission.json b/web/i18n/ar-TN/permission.json index eb1f06d9380..f9b4a1e72bb 100644 --- a/web/i18n/ar-TN/permission.json +++ b/web/i18n/ar-TN/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "إذن استثنائي لـ {{name}}", "accessRule.expandSection": "توسيع {{title}}", "accessRule.individualPermissionSettings": "إعدادات الأذونات الفردية", - "accessRule.individualPermissionSettingsTip": "عيّن استثناءات الأذونات لمتعاونين أو مجموعات محددة. تتجاوز هذه الإعدادات مستوى الوصول الافتراضي.", + "accessRule.individualPermissionSettingsTip": "عيّن استثناءات الأذونات لمتعاونين أو مجموعات محددة. تتجاوز هذه الإعدادات أذونات أدوارهم.", "accessRule.maintainer": "مشرف الصيانة", "accessRule.member": "عضو", "accessRule.newPermissionSet": "مجموعة أذونات جديدة", diff --git a/web/i18n/de-DE/permission.json b/web/i18n/de-DE/permission.json index 2a6d14d495f..5d0e971d61c 100644 --- a/web/i18n/de-DE/permission.json +++ b/web/i18n/de-DE/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Ausnahmeberechtigung für {{name}}", "accessRule.expandSection": "{{title}} ausklappen", "accessRule.individualPermissionSettings": "Individuelle Berechtigungseinstellungen", - "accessRule.individualPermissionSettingsTip": "Legen Sie Berechtigungsausnahmen für bestimmte Mitarbeiter oder Gruppen fest. Diese Einstellungen überschreiben die Standardzugriffsstufe.", + "accessRule.individualPermissionSettingsTip": "Legen Sie Berechtigungsausnahmen für bestimmte Mitarbeiter oder Gruppen fest. Diese Einstellungen überschreiben deren Rollenberechtigungen.", "accessRule.maintainer": "Betreuer", "accessRule.member": "Mitglied", "accessRule.newPermissionSet": "Neuer Berechtigungssatz", diff --git a/web/i18n/en-US/permission.json b/web/i18n/en-US/permission.json index d2b79d75d5c..1e69c097092 100644 --- a/web/i18n/en-US/permission.json +++ b/web/i18n/en-US/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Exception permission for {{name}}", "accessRule.expandSection": "Expand {{title}}", "accessRule.individualPermissionSettings": "Individual permission settings", - "accessRule.individualPermissionSettingsTip": "Set permission exceptions for specific collaborators or groups. These settings override the default access level.", + "accessRule.individualPermissionSettingsTip": "Set permission exceptions for specific collaborators or groups. These settings override their role permissions.", "accessRule.maintainer": "Maintainer", "accessRule.member": "Member", "accessRule.newPermissionSet": "New permission set", diff --git a/web/i18n/es-ES/permission.json b/web/i18n/es-ES/permission.json index bb015fa211b..7a75004f56f 100644 --- a/web/i18n/es-ES/permission.json +++ b/web/i18n/es-ES/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Permiso de excepción para {{name}}", "accessRule.expandSection": "Expandir {{title}}", "accessRule.individualPermissionSettings": "Ajustes de permisos individuales", - "accessRule.individualPermissionSettingsTip": "Establece excepciones de permisos para colaboradores o grupos específicos. Estos ajustes anulan el nivel de acceso predeterminado.", + "accessRule.individualPermissionSettingsTip": "Establece excepciones de permisos para colaboradores o grupos específicos. Estos ajustes anulan sus permisos de rol.", "accessRule.maintainer": "Mantenedor", "accessRule.member": "Miembro", "accessRule.newPermissionSet": "Nuevo conjunto de permisos", diff --git a/web/i18n/fa-IR/permission.json b/web/i18n/fa-IR/permission.json index 519d51c3cb5..ae57032c617 100644 --- a/web/i18n/fa-IR/permission.json +++ b/web/i18n/fa-IR/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "مجوز استثنا برای {{name}}", "accessRule.expandSection": "گسترش {{title}}", "accessRule.individualPermissionSettings": "تنظیمات مجوز فردی", - "accessRule.individualPermissionSettingsTip": "استثناهای مجوز را برای همکاران یا گروه‌های خاص تنظیم کنید. این تنظیمات سطح دسترسی پیش‌فرض را لغو می‌کنند.", + "accessRule.individualPermissionSettingsTip": "استثناهای مجوز را برای همکاران یا گروه‌های خاص تنظیم کنید. این تنظیمات مجوزهای نقش آن‌ها را لغو می‌کنند.", "accessRule.maintainer": "نگهدارنده", "accessRule.member": "عضو", "accessRule.newPermissionSet": "مجموعه مجوز جدید", diff --git a/web/i18n/fr-FR/permission.json b/web/i18n/fr-FR/permission.json index 969d78f2b6f..f8dd5339715 100644 --- a/web/i18n/fr-FR/permission.json +++ b/web/i18n/fr-FR/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Autorisation d'exception pour {{name}}", "accessRule.expandSection": "Développer {{title}}", "accessRule.individualPermissionSettings": "Paramètres d'autorisation individuels", - "accessRule.individualPermissionSettingsTip": "Définissez des exceptions d'autorisation pour des collaborateurs ou des groupes spécifiques. Ces paramètres remplacent le niveau d'accès par défaut.", + "accessRule.individualPermissionSettingsTip": "Définissez des exceptions d'autorisation pour des collaborateurs ou des groupes spécifiques. Ces paramètres remplacent leurs autorisations de rôle.", "accessRule.maintainer": "Mainteneur", "accessRule.member": "Membre", "accessRule.newPermissionSet": "Nouvel ensemble d'autorisations", diff --git a/web/i18n/hi-IN/permission.json b/web/i18n/hi-IN/permission.json index 9c764a03e08..aded335e454 100644 --- a/web/i18n/hi-IN/permission.json +++ b/web/i18n/hi-IN/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "{{name}} के लिए अपवाद अनुमति", "accessRule.expandSection": "{{title}} विस्तृत करें", "accessRule.individualPermissionSettings": "व्यक्तिगत अनुमति सेटिंग्स", - "accessRule.individualPermissionSettingsTip": "विशिष्ट सहयोगियों या समूहों के लिए अनुमति अपवाद सेट करें। ये सेटिंग्स डिफ़ॉल्ट एक्सेस स्तर को ओवरराइड करती हैं।", + "accessRule.individualPermissionSettingsTip": "विशिष्ट सहयोगियों या समूहों के लिए अनुमति अपवाद सेट करें। ये सेटिंग्स उनकी भूमिका अनुमतियों को ओवरराइड करती हैं।", "accessRule.maintainer": "रखरखावकर्ता", "accessRule.member": "सदस्य", "accessRule.newPermissionSet": "नया अनुमति सेट", diff --git a/web/i18n/id-ID/permission.json b/web/i18n/id-ID/permission.json index 26b9ed36915..073fbbaa6b8 100644 --- a/web/i18n/id-ID/permission.json +++ b/web/i18n/id-ID/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Izin pengecualian untuk {{name}}", "accessRule.expandSection": "Perluas {{title}}", "accessRule.individualPermissionSettings": "Pengaturan izin individu", - "accessRule.individualPermissionSettingsTip": "Tetapkan pengecualian izin untuk kolaborator atau grup tertentu. Pengaturan ini menggantikan tingkat akses default.", + "accessRule.individualPermissionSettingsTip": "Tetapkan pengecualian izin untuk kolaborator atau grup tertentu. Pengaturan ini menggantikan izin peran mereka.", "accessRule.maintainer": "Pengelola", "accessRule.member": "Anggota", "accessRule.newPermissionSet": "Set izin baru", diff --git a/web/i18n/it-IT/permission.json b/web/i18n/it-IT/permission.json index d4fb2011cf5..619166f56e5 100644 --- a/web/i18n/it-IT/permission.json +++ b/web/i18n/it-IT/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Permesso di eccezione per {{name}}", "accessRule.expandSection": "Espandi {{title}}", "accessRule.individualPermissionSettings": "Impostazioni dei permessi individuali", - "accessRule.individualPermissionSettingsTip": "Imposta eccezioni ai permessi per collaboratori o gruppi specifici. Queste impostazioni hanno la precedenza sul livello di accesso predefinito.", + "accessRule.individualPermissionSettingsTip": "Imposta eccezioni ai permessi per collaboratori o gruppi specifici. Queste impostazioni hanno la precedenza sui loro permessi di ruolo.", "accessRule.maintainer": "Manutentore", "accessRule.member": "Membro", "accessRule.newPermissionSet": "Nuovo set di permessi", diff --git a/web/i18n/ja-JP/permission.json b/web/i18n/ja-JP/permission.json index 01c7fc462fa..914c2df6091 100644 --- a/web/i18n/ja-JP/permission.json +++ b/web/i18n/ja-JP/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "{{name}} の例外権限", "accessRule.expandSection": "{{title}} を展開", "accessRule.individualPermissionSettings": "個別権限設定", - "accessRule.individualPermissionSettingsTip": "特定の共同編集者またはグループに権限の例外を設定します。これらの設定はデフォルトのアクセスレベルを上書きします。", + "accessRule.individualPermissionSettingsTip": "特定の共同編集者またはグループに権限の例外を設定します。これらの設定は、対象の共同編集者またはグループのロール権限を上書きします。", "accessRule.maintainer": "メンテナー", "accessRule.member": "メンバー", "accessRule.newPermissionSet": "新しい権限セット", diff --git a/web/i18n/ko-KR/permission.json b/web/i18n/ko-KR/permission.json index 0a12fe7babf..c8201b7fa45 100644 --- a/web/i18n/ko-KR/permission.json +++ b/web/i18n/ko-KR/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "{{name}}에 대한 예외 권한", "accessRule.expandSection": "{{title}} 펼치기", "accessRule.individualPermissionSettings": "개별 권한 설정", - "accessRule.individualPermissionSettingsTip": "특정 협업자 또는 그룹에 대한 권한 예외를 설정합니다. 이 설정은 기본 접근 수준을 재정의합니다.", + "accessRule.individualPermissionSettingsTip": "특정 협업자 또는 그룹에 대한 권한 예외를 설정합니다. 이 설정은 해당 협업자 또는 그룹의 역할 권한을 재정의합니다.", "accessRule.maintainer": "관리자", "accessRule.member": "멤버", "accessRule.newPermissionSet": "새 권한 집합", diff --git a/web/i18n/nl-NL/permission.json b/web/i18n/nl-NL/permission.json index 279a6313a7f..48858967519 100644 --- a/web/i18n/nl-NL/permission.json +++ b/web/i18n/nl-NL/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Uitzonderingsrecht voor {{name}}", "accessRule.expandSection": "{{title}} uitvouwen", "accessRule.individualPermissionSettings": "Individuele rechteninstellingen", - "accessRule.individualPermissionSettingsTip": "Stel rechtenuitzonderingen in voor specifieke samenwerkers of groepen. Deze instellingen overschrijven het standaard toegangsniveau.", + "accessRule.individualPermissionSettingsTip": "Stel rechtenuitzonderingen in voor specifieke samenwerkers of groepen. Deze instellingen overschrijven hun rolrechten.", "accessRule.maintainer": "Beheerder", "accessRule.member": "Lid", "accessRule.newPermissionSet": "Nieuwe rechtenset", diff --git a/web/i18n/pl-PL/permission.json b/web/i18n/pl-PL/permission.json index fcfcc908918..d525f59d791 100644 --- a/web/i18n/pl-PL/permission.json +++ b/web/i18n/pl-PL/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Wyjątkowe uprawnienie dla {{name}}", "accessRule.expandSection": "Rozwiń {{title}}", "accessRule.individualPermissionSettings": "Indywidualne ustawienia uprawnień", - "accessRule.individualPermissionSettingsTip": "Ustaw wyjątki uprawnień dla określonych współpracowników lub grup. Te ustawienia zastępują domyślny poziom dostępu.", + "accessRule.individualPermissionSettingsTip": "Ustaw wyjątki uprawnień dla określonych współpracowników lub grup. Te ustawienia zastępują uprawnienia ich ról.", "accessRule.maintainer": "Opiekun", "accessRule.member": "Członek", "accessRule.newPermissionSet": "Nowy zestaw uprawnień", diff --git a/web/i18n/pt-BR/permission.json b/web/i18n/pt-BR/permission.json index edef0bea18f..8b3a40acef4 100644 --- a/web/i18n/pt-BR/permission.json +++ b/web/i18n/pt-BR/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Permissão de exceção para {{name}}", "accessRule.expandSection": "Expandir {{title}}", "accessRule.individualPermissionSettings": "Configurações de permissão individuais", - "accessRule.individualPermissionSettingsTip": "Defina exceções de permissão para colaboradores ou grupos específicos. Essas configurações substituem o nível de acesso padrão.", + "accessRule.individualPermissionSettingsTip": "Defina exceções de permissão para colaboradores ou grupos específicos. Essas configurações substituem suas permissões de função.", "accessRule.maintainer": "Mantenedor", "accessRule.member": "Membro", "accessRule.newPermissionSet": "Novo conjunto de permissões", diff --git a/web/i18n/ro-RO/permission.json b/web/i18n/ro-RO/permission.json index 19e5815e357..d1aa056e57f 100644 --- a/web/i18n/ro-RO/permission.json +++ b/web/i18n/ro-RO/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Permisiune de excepție pentru {{name}}", "accessRule.expandSection": "Extinde {{title}}", "accessRule.individualPermissionSettings": "Setări individuale de permisiuni", - "accessRule.individualPermissionSettingsTip": "Setează excepții de permisiuni pentru colaboratori sau grupuri specifice. Aceste setări înlocuiesc nivelul de acces implicit.", + "accessRule.individualPermissionSettingsTip": "Setează excepții de permisiuni pentru colaboratori sau grupuri specifice. Aceste setări înlocuiesc permisiunile rolurilor lor.", "accessRule.maintainer": "Întreținător", "accessRule.member": "Membru", "accessRule.newPermissionSet": "Set nou de permisiuni", diff --git a/web/i18n/ru-RU/permission.json b/web/i18n/ru-RU/permission.json index 87578651c7f..2df6bcd609c 100644 --- a/web/i18n/ru-RU/permission.json +++ b/web/i18n/ru-RU/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Исключение из прав для {{name}}", "accessRule.expandSection": "Развернуть {{title}}", "accessRule.individualPermissionSettings": "Индивидуальные настройки прав", - "accessRule.individualPermissionSettingsTip": "Задайте исключения из прав для определенных участников или групп. Эти настройки переопределяют уровень доступа по умолчанию.", + "accessRule.individualPermissionSettingsTip": "Задайте исключения из прав для определенных участников или групп. Эти настройки переопределяют права их ролей.", "accessRule.maintainer": "Сопровождающий", "accessRule.member": "Участник", "accessRule.newPermissionSet": "Новый набор прав", diff --git a/web/i18n/sl-SI/permission.json b/web/i18n/sl-SI/permission.json index 28fbe824fdb..65d754e59ff 100644 --- a/web/i18n/sl-SI/permission.json +++ b/web/i18n/sl-SI/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Izjemno dovoljenje za {{name}}", "accessRule.expandSection": "Razširi {{title}}", "accessRule.individualPermissionSettings": "Individualne nastavitve dovoljenj", - "accessRule.individualPermissionSettingsTip": "Nastavite izjeme dovoljenj za določene sodelavce ali skupine. Te nastavitve preglasijo privzeto raven dostopa.", + "accessRule.individualPermissionSettingsTip": "Nastavite izjeme dovoljenj za določene sodelavce ali skupine. Te nastavitve preglasijo dovoljenja njihovih vlog.", "accessRule.maintainer": "Vzdrževalec", "accessRule.member": "Član", "accessRule.newPermissionSet": "Nov nabor dovoljenj", diff --git a/web/i18n/th-TH/permission.json b/web/i18n/th-TH/permission.json index a9cd1566b0e..1d7baf5bd37 100644 --- a/web/i18n/th-TH/permission.json +++ b/web/i18n/th-TH/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "สิทธิ์ข้อยกเว้นสําหรับ {{name}}", "accessRule.expandSection": "ขยาย {{title}}", "accessRule.individualPermissionSettings": "การตั้งค่าสิทธิ์เฉพาะบุคคล", - "accessRule.individualPermissionSettingsTip": "ตั้งค่าข้อยกเว้นสิทธิ์สําหรับผู้ร่วมงานหรือกลุ่มที่เฉพาะเจาะจง การตั้งค่าเหล่านี้จะแทนที่ระดับการเข้าถึงเริ่มต้น", + "accessRule.individualPermissionSettingsTip": "ตั้งค่าข้อยกเว้นสิทธิ์สําหรับผู้ร่วมงานหรือกลุ่มที่เฉพาะเจาะจง การตั้งค่าเหล่านี้จะแทนที่สิทธิ์ตามบทบาทของพวกเขา", "accessRule.maintainer": "ผู้ดูแล", "accessRule.member": "สมาชิก", "accessRule.newPermissionSet": "ชุดสิทธิ์ใหม่", diff --git a/web/i18n/tr-TR/permission.json b/web/i18n/tr-TR/permission.json index e8578b4384c..83993f04df1 100644 --- a/web/i18n/tr-TR/permission.json +++ b/web/i18n/tr-TR/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "{{name}} için istisna izni", "accessRule.expandSection": "{{title}} genişlet", "accessRule.individualPermissionSettings": "Bireysel izin ayarları", - "accessRule.individualPermissionSettingsTip": "Belirli işbirlikçiler veya gruplar için izin istisnaları ayarlayın. Bu ayarlar varsayılan erişim düzeyini geçersiz kılar.", + "accessRule.individualPermissionSettingsTip": "Belirli işbirlikçiler veya gruplar için izin istisnaları ayarlayın. Bu ayarlar onların rol izinlerini geçersiz kılar.", "accessRule.maintainer": "Bakımcı", "accessRule.member": "Üye", "accessRule.newPermissionSet": "Yeni izin kümesi", diff --git a/web/i18n/uk-UA/permission.json b/web/i18n/uk-UA/permission.json index 101c208f5ab..08a426f0510 100644 --- a/web/i18n/uk-UA/permission.json +++ b/web/i18n/uk-UA/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Винятковий дозвіл для {{name}}", "accessRule.expandSection": "Розгорнути {{title}}", "accessRule.individualPermissionSettings": "Індивідуальні налаштування дозволів", - "accessRule.individualPermissionSettingsTip": "Установіть винятки дозволів для конкретних співавторів або груп. Ці налаштування перевизначають типовий рівень доступу.", + "accessRule.individualPermissionSettingsTip": "Установіть винятки дозволів для конкретних співавторів або груп. Ці налаштування перевизначають дозволи їхніх ролей.", "accessRule.maintainer": "Супроводжувач", "accessRule.member": "Учасник", "accessRule.newPermissionSet": "Новий набір дозволів", diff --git a/web/i18n/vi-VN/permission.json b/web/i18n/vi-VN/permission.json index 58b59832745..224165fcb7d 100644 --- a/web/i18n/vi-VN/permission.json +++ b/web/i18n/vi-VN/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "Quyền ngoại lệ cho {{name}}", "accessRule.expandSection": "Mở rộng {{title}}", "accessRule.individualPermissionSettings": "Cài đặt quyền riêng lẻ", - "accessRule.individualPermissionSettingsTip": "Đặt các ngoại lệ về quyền cho các cộng tác viên hoặc nhóm cụ thể. Các cài đặt này sẽ ghi đè cấp độ truy cập mặc định.", + "accessRule.individualPermissionSettingsTip": "Đặt các ngoại lệ về quyền cho các cộng tác viên hoặc nhóm cụ thể. Các cài đặt này sẽ ghi đè quyền vai trò của họ.", "accessRule.maintainer": "Người bảo trì", "accessRule.member": "Thành viên", "accessRule.newPermissionSet": "Bộ quyền mới", diff --git a/web/i18n/zh-Hans/permission.json b/web/i18n/zh-Hans/permission.json index ce7ccaf6bda..caf858ae9b6 100644 --- a/web/i18n/zh-Hans/permission.json +++ b/web/i18n/zh-Hans/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "{{name}} 的例外权限", "accessRule.expandSection": "展开 {{title}}", "accessRule.individualPermissionSettings": "个人权限设置", - "accessRule.individualPermissionSettingsTip": "为指定协作者或群组设置权限例外。这些设置会覆盖默认访问级别。", + "accessRule.individualPermissionSettingsTip": "为指定协作者或群组设置权限例外。这些设置会覆盖其角色权限。", "accessRule.maintainer": "维护者", "accessRule.member": "成员", "accessRule.newPermissionSet": "新建权限集", diff --git a/web/i18n/zh-Hant/permission.json b/web/i18n/zh-Hant/permission.json index a0b97191112..fec8bdca1c5 100644 --- a/web/i18n/zh-Hant/permission.json +++ b/web/i18n/zh-Hant/permission.json @@ -20,7 +20,7 @@ "accessRule.exceptionPermissionFor": "{{name}} 的例外權限", "accessRule.expandSection": "展開 {{title}}", "accessRule.individualPermissionSettings": "個人權限設定", - "accessRule.individualPermissionSettingsTip": "為指定協作者或群組設定權限例外。這些設定會覆蓋預設存取層級。", + "accessRule.individualPermissionSettingsTip": "為指定協作者或群組設定權限例外。這些設定會覆蓋其角色權限。", "accessRule.maintainer": "維護者", "accessRule.member": "成員", "accessRule.newPermissionSet": "新增權限集", From 187501f53e525c13c78e703640f0901fb59e106b Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 18:17:34 +0900 Subject: [PATCH 33/63] test: use sqlite3 session in test_reset_encrypt_key_pair (#38673) --- .../commands/test_reset_encrypt_key_pair.py | 176 ++++++++++++------ 1 file changed, 118 insertions(+), 58 deletions(-) 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 31b4d71d0ff..59a12a616b5 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 @@ -1,15 +1,24 @@ -"""Unit tests for the reset-encrypt-key-pair CLI command (#35396). +"""SQLite-backed tests for the reset-encrypt-key-pair CLI command (#35396). The command must purge every table that stores ciphertext encrypted with the tenant's asymmetric key, otherwise stale rows cause downstream API failures such as `/console/api/workspaces/current/tool-providers` returning 500. +Tests bind the command-owned transaction to the fixture engine and assert the +committed state rather than inspecting fabricated ``Session.execute`` calls. """ -from unittest.mock import MagicMock, patch +from types import SimpleNamespace + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session import commands from commands import system as system_commands -from models.provider import Provider, ProviderModel +from core.tools.entities.tool_entities import ApiProviderSchemaType +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 @@ -21,17 +30,60 @@ def _invoke_reset() -> int: return 0 -def _delete_targets(session_mock: MagicMock) -> list: - """Extract the model class targeted by each `delete(...)` call on the session.""" - targets = [] - for call in session_mock.execute.call_args_list: - stmt = call.args[0] - # `delete(Foo)` constructs a `Delete` statement whose entity is `Foo`. - try: - targets.append(stmt.table.name) - except AttributeError: - targets.append(repr(stmt)) - return targets +TENANT_ID = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT_ID = "11111111-1111-1111-1111-111111111112" +USER_ID = "22222222-2222-2222-2222-222222222222" + + +def _tenant(tenant_id: str, *, name: str = "Test tenant") -> Tenant: + tenant = Tenant(name=name, encrypt_public_key="old-key") + tenant.id = tenant_id + return tenant + + +def _encrypted_rows(tenant_id: str, *, suffix: str = "1") -> tuple[object, ...]: + """Build one persisted credential-bearing row for every purge target.""" + return ( + Provider(tenant_id=tenant_id, provider_name=f"provider-{suffix}"), + ProviderModel( + tenant_id=tenant_id, + provider_name=f"provider-{suffix}", + model_name=f"model-{suffix}", + model_type=ModelType.LLM, + ), + BuiltinToolProvider( + name=f"builtin-credential-{suffix}", + tenant_id=tenant_id, + user_id=USER_ID, + provider=f"builtin-{suffix}", + encrypted_credentials="ciphertext", + ), + ApiToolProvider( + name=f"api-{suffix}", + icon="icon", + schema="{}", + schema_type_str=ApiProviderSchemaType.OPENAPI, + user_id=USER_ID, + tenant_id=tenant_id, + description="description", + tools_str="[]", + credentials_str="{}", + ), + MCPToolProvider( + name=f"mcp-{suffix}", + server_identifier=f"server-{suffix}", + server_url="ciphertext", + server_url_hash=f"hash-{suffix}", + icon=None, + tenant_id=tenant_id, + user_id=USER_ID, + encrypted_credentials="ciphertext", + ), + ) + + +def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) -> None: + monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=session.get_bind())) def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): @@ -44,65 +96,73 @@ def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): assert "only for SELF_HOSTED" in captured.out -def test_reset_purges_provider_and_tool_tables_for_each_tenant(monkeypatch, capsys): +@pytest.mark.parametrize( + "sqlite_session", + [(Tenant, Provider, ProviderModel, BuiltinToolProvider, ApiToolProvider, MCPToolProvider)], + indirect=True, +) +def test_reset_purges_provider_and_tool_tables_for_each_tenant( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], sqlite_session: Session +) -> 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, "EDITION", "SELF_HOSTED") monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") + _bind_command_to_sqlite(monkeypatch, sqlite_session) - fake_tenant = MagicMock(id="tenant-abc", encrypt_public_key="old-key") - session = MagicMock() - session.scalars.return_value.all.return_value = [fake_tenant] + tenant = _tenant(TENANT_ID) + other_tenant = _tenant(OTHER_TENANT_ID, name="Other tenant") + system_provider = Provider( + tenant_id=TENANT_ID, + provider_name="system-provider", + provider_type=ProviderType.SYSTEM, + ) + sqlite_session.add_all((tenant, other_tenant, system_provider, *_encrypted_rows(TENANT_ID))) + sqlite_session.commit() - fake_sessionmaker = MagicMock() - fake_sessionmaker.begin.return_value.__enter__.return_value = session - fake_sessionmaker.begin.return_value.__exit__.return_value = False - - with ( - patch.object(system_commands, "db", MagicMock()), - patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker), - ): - exit_code = _invoke_reset() + exit_code = _invoke_reset() captured = capsys.readouterr() assert exit_code == 0 - assert "tenant-abc" in captured.out + assert TENANT_ID in captured.out - # New key pair generated and assigned. - assert fake_tenant.encrypt_public_key == "new-key-tenant-abc" - - # Every encrypted-credential table should have been purged for this tenant. - table_names = _delete_targets(session) - expected = { - Provider.__tablename__, - ProviderModel.__tablename__, - BuiltinToolProvider.__tablename__, - ApiToolProvider.__tablename__, - MCPToolProvider.__tablename__, - } - assert expected.issubset(set(table_names)), f"missing purges: expected {expected}, got {table_names}" + sqlite_session.expire_all() + assert sqlite_session.get(Tenant, TENANT_ID).encrypt_public_key == f"new-key-{TENANT_ID}" + assert sqlite_session.get(Tenant, OTHER_TENANT_ID).encrypt_public_key == f"new-key-{OTHER_TENANT_ID}" + assert sqlite_session.scalars(select(Provider).where(Provider.provider_type == ProviderType.CUSTOM)).all() == [] + assert sqlite_session.scalars(select(ProviderModel)).all() == [] + assert sqlite_session.scalars(select(BuiltinToolProvider)).all() == [] + assert sqlite_session.scalars(select(ApiToolProvider)).all() == [] + assert sqlite_session.scalars(select(MCPToolProvider)).all() == [] + assert ( + sqlite_session.scalar(select(Provider).where(Provider.provider_type == ProviderType.SYSTEM)) is system_provider + ) -def test_reset_iterates_all_tenants(monkeypatch, capsys): +@pytest.mark.parametrize( + "sqlite_session", + [(Tenant, Provider, ProviderModel, BuiltinToolProvider, ApiToolProvider, MCPToolProvider)], + indirect=True, +) +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, "EDITION", "SELF_HOSTED") monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") - tenants = [MagicMock(id=f"tenant-{i}", encrypt_public_key="old") for i in range(3)] - session = MagicMock() - session.scalars.return_value.all.return_value = tenants + _bind_command_to_sqlite(monkeypatch, sqlite_session) + tenant_ids = [f"11111111-1111-1111-1111-{index:012d}" for index in range(3)] + tenants = [_tenant(tenant_id, name=f"Tenant {index}") for index, tenant_id in enumerate(tenant_ids)] + for index, tenant in enumerate(tenants): + sqlite_session.add(tenant) + sqlite_session.add_all(_encrypted_rows(tenant.id, suffix=str(index))) + sqlite_session.commit() - fake_sessionmaker = MagicMock() - fake_sessionmaker.begin.return_value.__enter__.return_value = session - fake_sessionmaker.begin.return_value.__exit__.return_value = False + assert _invoke_reset() == 0 - with ( - patch.object(system_commands, "db", MagicMock()), - patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker), - ): - _invoke_reset() - - # Five purges per tenant × 3 tenants = 15 execute calls. - assert session.execute.call_count == 15 - for tenant in tenants: - assert tenant.encrypt_public_key == f"new-key-{tenant.id}" + sqlite_session.expire_all() + persisted_tenants = sqlite_session.scalars(select(Tenant).order_by(Tenant.id)).all() + assert [tenant.encrypt_public_key for tenant in persisted_tenants] == [ + f"new-key-{tenant_id}" for tenant_id in tenant_ids + ] + for model in (Provider, ProviderModel, BuiltinToolProvider, ApiToolProvider, MCPToolProvider): + assert sqlite_session.scalars(select(model)).all() == [] From 0f9fffb7a2dd55da8978997b4ed6352a5eb49e6a Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 22 Jul 2026 17:17:55 +0800 Subject: [PATCH 34/63] fix(agent): isolate build and preview chat conversations (#39405) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 24 +++++- api/controllers/console/app/completion.py | 15 +++- ...e7b9c10_scope_agent_debug_conversations.py | 77 +++++++++++++++++++ api/models/agent.py | 15 +++- api/openapi/markdown/console-openapi.md | 12 +++ api/services/agent/roster_service.py | 77 +++++++++++++++---- .../console/agent/test_agent_controllers.py | 54 +++++++++++-- .../unit_tests/controllers/test_swagger.py | 21 +++++ .../services/agent/test_agent_services.py | 51 +++++++++++- .../generated/api/console/agent/orpc.gen.ts | 8 +- .../generated/api/console/agent/types.gen.ts | 10 ++- .../generated/api/console/agent/zod.gen.ts | 73 ++++++++++-------- 12 files changed, 370 insertions(+), 67 deletions(-) create mode 100644 api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index c06a5710cdd..6b0db7a0b48 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -62,7 +62,7 @@ from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account -from models.agent import Agent, AgentStatus +from models.agent import Agent, AgentConfigDraftType, AgentStatus from models.agent_config_entities import AgentSoulConfig from models.enums import ApiTokenType from models.model import ApiToken, App, IconType @@ -266,6 +266,13 @@ class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_message_count: int = 0 +class AgentDebugConversationRefreshPayload(BaseModel): + draft_type: AgentConfigDraftType = Field( + default=AgentConfigDraftType.DEBUG_BUILD, + description="Agent draft surface whose conversation should be refreshed", + ) + + class AgentPublishPayload(BaseModel): version_note: str | None = Field(default=None, description="Optional note for this published Agent version") @@ -309,6 +316,7 @@ register_schema_models( AgentAppCopyPayload, AgentPublishPayload, AgentBuildDraftCheckoutPayload, + AgentDebugConversationRefreshPayload, ComposerSavePayload, AgentApiStatusPayload, AgentInviteOptionsQuery, @@ -392,6 +400,7 @@ def _serialize_agent_app_detail( tenant_id=app_model.tenant_id, agent_id=agent.id, account_id=current_user.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, commit=False, ) message_count = roster_service.count_agent_app_debug_conversation_messages( @@ -439,6 +448,7 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_ tenant_id=tenant_id, agents=list(agents_by_app_id.values()), account_id=current_user.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) payload = AgentAppPagination.model_validate( app_pagination, @@ -655,6 +665,16 @@ class AgentAppApi(Resource): @console_ns.route("/agent//debug-conversation/refresh") class AgentDebugConversationRefreshApi(Resource): + @console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__]) + @console_ns.doc( + params={ + "payload": { + "in": "body", + "required": False, + "schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"}, + } + } + ) @console_ns.response( 200, "Agent debug conversation refreshed", @@ -669,10 +689,12 @@ class AgentDebugConversationRefreshApi(Resource): @with_current_tenant_id @with_session def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {}) debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id( tenant_id=tenant_id, agent_id=str(agent_id), account_id=current_user.id, + draft_type=args.draft_type, ) return AgentDebugConversationRefreshResponse( debug_conversation_id=debug_conversation_id, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index ae76fee38d9..3fe721def62 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -49,6 +49,7 @@ from libs import helper from libs.helper import uuid_value from libs.login import login_required from models import Account +from models.agent import AgentConfigDraftType from models.model import App, AppMode from services.agent.errors import AgentNotFoundError from services.agent.roster_service import AgentRosterService @@ -343,14 +344,23 @@ class AgentChatMessageStopApi(Resource): def _resolve_current_user_agent_debug_conversation_id( - *, session: Session, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None + *, + session: Session, + current_tenant_id: str, + current_user: Account, + app_model: App, + agent_id: str | None, + draft_type: AgentConfigDraftType, ) -> str: + """Resolve the current editor's conversation without crossing draft surfaces.""" + roster_service = AgentRosterService(session) if agent_id: return roster_service.get_or_create_agent_app_debug_conversation_id( tenant_id=current_tenant_id, agent_id=agent_id, account_id=current_user.id, + draft_type=draft_type, ) agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id)) @@ -360,6 +370,7 @@ def _resolve_current_user_agent_debug_conversation_id( tenant_id=current_tenant_id, agent_id=agent.id, account_id=current_user.id, + draft_type=draft_type, ) @@ -382,6 +393,7 @@ def _create_chat_message( current_user=current_user, app_model=app_model, agent_id=agent_id, + draft_type=AgentConfigDraftType(args_model.draft_type), ) if args_model.conversation_id and args_model.conversation_id != debug_conversation_id: raise NotFound("Conversation Not Exists.") @@ -418,6 +430,7 @@ def _create_build_chat_finalization_message( current_user=current_user, app_model=app_model, agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) args: dict[str, Any] = { "query": _BUILD_CHAT_FINALIZATION_QUERY, diff --git a/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py new file mode 100644 index 00000000000..2e3e41e2b82 --- /dev/null +++ b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py @@ -0,0 +1,77 @@ +"""scope agent debug conversations by draft type + +Revision ID: d2825e7b9c10 +Revises: b8c9d0e1f2a3 +Create Date: 2026-07-22 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "d2825e7b9c10" +down_revision = "b8c9d0e1f2a3" +branch_labels = None +depends_on = None + + +def upgrade(): + # Existing pointers have always represented Build chat because the Agent + # detail API exposes them as ``debug_conversation_id`` for that surface. + op.add_column( + "agent_debug_conversations", + sa.Column( + "draft_type", + sa.String(length=32), + nullable=False, + server_default=sa.text("'debug_build'"), + ), + ) + op.drop_constraint( + "agent_debug_conversation_agent_account_unique", + "agent_debug_conversations", + type_="unique", + ) + op.create_unique_constraint( + "agent_debug_conversation_agent_account_draft_type_unique", + "agent_debug_conversations", + ["tenant_id", "agent_id", "account_id", "draft_type"], + ) + + +def downgrade(): + debug_conversations = sa.table( + "agent_debug_conversations", + sa.column("tenant_id", models.types.StringUUID()), + sa.column("agent_id", models.types.StringUUID()), + sa.column("account_id", models.types.StringUUID()), + sa.column("draft_type", sa.String(length=32)), + ) + build_conversations = debug_conversations.alias("build_conversations") + op.get_bind().execute( + sa.delete(debug_conversations).where( + debug_conversations.c.draft_type == "draft", + sa.exists( + sa.select(sa.literal(1)).where( + build_conversations.c.tenant_id == debug_conversations.c.tenant_id, + build_conversations.c.agent_id == debug_conversations.c.agent_id, + build_conversations.c.account_id == debug_conversations.c.account_id, + build_conversations.c.draft_type == "debug_build", + ) + ), + ) + ) + op.drop_constraint( + "agent_debug_conversation_agent_account_draft_type_unique", + "agent_debug_conversations", + type_="unique", + ) + op.create_unique_constraint( + "agent_debug_conversation_agent_account_unique", + "agent_debug_conversations", + ["tenant_id", "agent_id", "account_id"], + ) + op.drop_column("agent_debug_conversations", "draft_type") diff --git a/api/models/agent.py b/api/models/agent.py index 467d7a4b753..cd3d371481d 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -222,11 +222,13 @@ class Agent(DefaultFieldsMixin, Base): class AgentDebugConversation(DefaultFieldsMixin, Base): - """Per-account console debug conversation for an Agent App. + """Per-account, per-draft console debug conversation for an Agent App. Agent App preview state must be isolated by editor account. The Agent row is shared by everyone in the workspace, so this table owns the user-specific - conversation pointer used by console debug chat. + conversation pointers used by console debug chat. ``draft`` is the Preview + conversation and ``debug_build`` is the Build conversation; they must never + share persisted messages or runtime sessions. """ __tablename__ = "agent_debug_conversations" @@ -236,7 +238,8 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): "tenant_id", "agent_id", "account_id", - name="agent_debug_conversation_agent_account_unique", + "draft_type", + name="agent_debug_conversation_agent_account_draft_type_unique", ), Index("agent_debug_conversation_conversation_idx", "conversation_id"), Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"), @@ -246,6 +249,12 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + draft_type: Mapped[AgentConfigDraftType] = mapped_column( + EnumText(AgentConfigDraftType, length=32), + nullable=False, + default=AgentConfigDraftType.DEBUG_BUILD, + server_default=sa.text("'debug_build'"), + ) conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index bc3768f4ae1..a5bb29e1917 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -959,6 +959,12 @@ Stop a running Agent App chat message generation | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **application/json**: [AgentDebugConversationRefreshPayload](#agentdebugconversationrefreshpayload)
| + #### Responses | Code | Description | Schema | @@ -13906,6 +13912,12 @@ Stable Agent Soul reference to one normalized skill archive. | date | string | | Yes | | message_count | integer | | Yes | +#### AgentDebugConversationRefreshPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | Agent draft surface whose conversation should be refreshed | No | + #### AgentDebugConversationRefreshResponse | Name | Type | Description | Required | diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index c6087843ae0..3d9401facf3 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -430,7 +430,11 @@ class AgentRosterService: agent.active_config_has_model = agent_soul_has_model(soul) agent.active_config_is_published = False self._session.flush() - self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + ) return agent def create_hidden_backing_app_for_workflow_agent( @@ -527,7 +531,11 @@ class AgentRosterService: self._session.flush() return backing_app.id - def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str: + def _get_or_create_agent_app_debug_conversation( + self, *, agent: Agent, account_id: str, draft_type: AgentConfigDraftType + ) -> str: + """Return the editor's conversation for one Agent draft surface.""" + backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id) if not backing_app_id: raise AgentNotFoundError() @@ -537,6 +545,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == agent.tenant_id, AgentDebugConversation.agent_id == agent.id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, ) ) if mapping is not None: @@ -570,6 +579,7 @@ class AgentRosterService: agent_id=agent.id, app_id=backing_app_id, account_id=account_id, + draft_type=draft_type, conversation_id=conversation_id, ) ) @@ -577,9 +587,15 @@ class AgentRosterService: return conversation_id def get_or_create_agent_app_debug_conversation_id( - self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + commit: bool = True, ) -> str: - """Return the current editor's debug conversation for an Agent App.""" + """Return the current editor's Build or Preview conversation for an Agent App.""" agent = self._session.scalar( select(Agent).where( @@ -591,13 +607,24 @@ class AgentRosterService: if agent is None: raise AgentNotFoundError() - conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + conversation_id = self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + draft_type=draft_type, + ) if commit: self._session.commit() return conversation_id - def load_agent_app_debug_conversation_id(self, *, tenant_id: str, agent_id: str, account_id: str) -> str | None: - """Return the current editor's existing debug conversation without creating or repairing rows.""" + def load_agent_app_debug_conversation_id( + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + ) -> str | None: + """Return the editor's existing scoped conversation without creating or repairing rows.""" return self._session.scalar( select(Conversation.id) @@ -606,6 +633,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, AgentDebugConversation.app_id == Conversation.app_id, Conversation.from_source == ConversationFromSource.CONSOLE, Conversation.from_account_id == account_id, @@ -626,16 +654,21 @@ class AgentRosterService: ) def refresh_agent_app_debug_conversation_id( - self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + commit: bool = True, ) -> str: - """Start a new console debug conversation for the current Agent App editor. + """Start a new scoped console conversation for the current Agent App editor. - If this account already has a debug conversation mapping, the previous + If this account already has a mapping for the requested draft surface, the previous conversation is abandoned first: any ACTIVE conversation-owned Agent runtime sessions for that old conversation are sent through best-effort - backend cleanup and then retired locally even when enqueueing fails. - The debug mapping is then repointed to the freshly created - conversation. + backend cleanup and then retired locally even when enqueueing fails. The + other draft surface is left untouched. """ agent = self._session.scalar( @@ -663,6 +696,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, ) ) if mapping is None: @@ -672,6 +706,7 @@ class AgentRosterService: agent_id=agent_id, app_id=backing_app_id, account_id=account_id, + draft_type=draft_type, conversation_id=conversation_id, ) ) @@ -683,6 +718,7 @@ class AgentRosterService: tenant_id=tenant_id, agent_id=agent_id, account_id=account_id, + draft_type=draft_type, app_id=previous_app_id or backing_app_id, conversation_id=previous_conversation_id, ) @@ -699,6 +735,7 @@ class AgentRosterService: tenant_id: str, agent_id: str, account_id: str, + draft_type: AgentConfigDraftType, app_id: str, conversation_id: str, ) -> None: @@ -727,7 +764,8 @@ class AgentRosterService: session_snapshot=stored_session.session_snapshot, runtime_layer_specs=stored_session.runtime_layer_specs, idempotency_key=( - f"{tenant_id}:{agent_id}:{account_id}:{conversation_id}:debug-session-cleanup:" + f"{tenant_id}:{agent_id}:{account_id}:{draft_type.value}:{conversation_id}:" + "debug-session-cleanup:" f"{stored_session.scope.agent_id}:" f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" f"{stored_session.backend_run_id or 'no-run'}" @@ -738,6 +776,7 @@ class AgentRosterService: "conversation_id": stored_session.scope.conversation_id, "agent_id": stored_session.scope.agent_id, "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, + "draft_type": draft_type.value, "previous_agent_backend_run_id": stored_session.backend_run_id, }, ) @@ -772,9 +811,14 @@ class AgentRosterService: ) def load_or_create_agent_app_debug_conversation_ids_by_agent_id( - self, *, tenant_id: str, agents: list[Agent], account_id: str + self, + *, + tenant_id: str, + agents: list[Agent], + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, ) -> dict[str, str]: - """Return per-account debug conversations for a page of Agent Apps.""" + """Return per-account scoped conversations for a page of Agent Apps.""" conversation_ids_by_agent_id: dict[str, str] = {} changed = False @@ -784,6 +828,7 @@ class AgentRosterService: conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( agent=agent, account_id=account_id, + draft_type=draft_type, ) changed = True if changed: 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 b026c0e0c85..ee13618fe1b 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 @@ -53,6 +53,7 @@ from controllers.console.app.message import ( AgentMessageFeedbackApi, AgentMessageSuggestedQuestionApi, ) +from models.agent import AgentConfigDraftType from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant @@ -371,6 +372,7 @@ def test_agent_app_list_and_create_use_agent_route( "tenant_id": "tenant-1", "agent_id": "agent-created", "account_id": account_id, + "draft_type": AgentConfigDraftType.DEBUG_BUILD, "commit": False, } @@ -544,8 +546,19 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( } -def test_agent_debug_conversation_refresh_uses_current_user( - app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +@pytest.mark.parametrize( + ("payload", "expected_draft_type"), + [ + (None, AgentConfigDraftType.DEBUG_BUILD), + ({"draft_type": "draft"}, AgentConfigDraftType.DRAFT), + ], +) +def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + payload: dict[str, str] | None, + expected_draft_type: AgentConfigDraftType, ) -> None: agent_id = "00000000-0000-0000-0000-000000000001" captured: dict[str, object] = {} @@ -557,7 +570,9 @@ def test_agent_debug_conversation_refresh_uses_current_user( monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService()) with app.test_request_context( - "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", method="POST" + "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", + method="POST", + json=payload, ): response = unwrap(AgentDebugConversationRefreshApi.post)( AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id), agent_id @@ -567,7 +582,12 @@ def test_agent_debug_conversation_refresh_uses_current_user( "debug_conversation_has_messages": False, "debug_conversation_message_count": 0, } - assert captured == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + assert captured == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "draft_type": expected_draft_type, + } def test_agent_publish_and_build_draft_routes_call_composer_service( @@ -1456,6 +1476,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt( "current_user": SimpleNamespace(id=account_id), "app_model": app_model, "agent_id": "agent-1", + "draft_type": AgentConfigDraftType.DEBUG_BUILD, } generate_call = cast(dict[str, object], captured["generate"]) assert generate_call["app_model"] is app_model @@ -1520,11 +1541,15 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( captured.update(kwargs) return {"answer": "ok"} + def resolve_debug_conversation(**kwargs: object) -> str: + captured["resolve_debug_conversation"] = kwargs + return "debug-conversation-1" + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) monkeypatch.setattr( completion_controller, "_resolve_current_user_agent_debug_conversation_id", - lambda **kwargs: "debug-conversation-1", + resolve_debug_conversation, ) monkeypatch.setattr( completion_controller.helper, "compact_generate_response", lambda response: {"response": response} @@ -1544,6 +1569,7 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( assert args["conversation_id"] == "debug-conversation-1" assert args["auto_generate_name"] is False assert args["external_trace_id"] == "trace-1" + assert cast(dict[str, object], captured["resolve_debug_conversation"])["draft_type"] == AgentConfigDraftType.DRAFT def test_agent_chat_helper_ignores_private_exit_intent_payload_key( @@ -1642,6 +1668,7 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app current_user=SimpleNamespace(id="account-1"), app_model=SimpleNamespace(id="app-1"), agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, ) fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id( session="session-1", # type: ignore[arg-type] @@ -1649,13 +1676,26 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app current_user=SimpleNamespace(id="account-1"), app_model=SimpleNamespace(id="app-1"), agent_id=None, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) assert explicit_id == "debug-agent-1" assert fallback_id == "debug-backing-agent" - assert calls[1] == {"get_or_create": {"tenant_id": "tenant-1", "agent_id": "agent-1", "account_id": "account-1"}} + assert calls[1] == { + "get_or_create": { + "tenant_id": "tenant-1", + "agent_id": "agent-1", + "account_id": "account-1", + "draft_type": AgentConfigDraftType.DRAFT, + } + } assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}} assert calls[4] == { - "get_or_create": {"tenant_id": "tenant-1", "agent_id": "backing-agent", "account_id": "account-1"} + "get_or_create": { + "tenant_id": "tenant-1", + "agent_id": "backing-agent", + "account_id": "account-1", + "draft_type": AgentConfigDraftType.DEBUG_BUILD, + } } diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index 667058de95e..149af9ff76a 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -574,6 +574,27 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest assert params["avatar"]["required"] is True +def test_console_agent_debug_conversation_refresh_body_is_optional(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(console_bp) + + payload = app.test_client().get("/console/api/openapi.json").get_json() + operation = payload["paths"]["/agent/{agent_id}/debug-conversation/refresh"]["post"] + request_body = operation["requestBody"] + + assert request_body["required"] is False + assert request_body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AgentDebugConversationRefreshPayload" + } + assert "AgentDebugConversationRefreshPayload" in payload["components"]["schemas"] + + def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.console import bp as console_bp diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index eb8f87fc1a9..a70eb496da6 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -2624,7 +2624,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr( AgentRosterService, "_get_or_create_agent_app_debug_conversation", - lambda self, *, agent, account_id: "debug-conversation-1", + lambda self, *, agent, account_id, draft_type: "debug-conversation-1", ) payload = roster_service.RosterAgentCreatePayload( name="Analyst", @@ -2730,6 +2730,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): assert created_mapping.tenant_id == "tenant-1" assert created_mapping.agent_id == "agent-1" assert created_mapping.account_id == "account-1" + assert created_mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD assert create_session.commits == 1 existing_mapping = AgentDebugConversation( @@ -2737,6 +2738,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent_id="agent-1", app_id="app-1", account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="existing-conversation", ) reuse_session = FakeSession(scalar=[agent, existing_mapping, "existing-conversation"]) @@ -2754,6 +2756,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent_id="agent-1", app_id="app-1", account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="deleted-conversation", ) recreate_session = FakeSession(scalar=[agent, stale_mapping, None]) @@ -2768,6 +2771,42 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): assert recreate_session.commits == 1 +def test_agent_app_debug_conversations_are_isolated_by_draft_type(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + app_id="app-1", + name="Analyst", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + ) + session = FakeSession(scalar=[agent, None, agent, None]) + service = AgentRosterService(session) + + build_conversation_id = service.get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, + ) + preview_conversation_id = service.get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + draft_type=AgentConfigDraftType.DRAFT, + ) + + mappings = [value for value in session.added if isinstance(value, AgentDebugConversation)] + assert build_conversation_id != preview_conversation_id + assert {mapping.draft_type for mapping in mappings} == { + AgentConfigDraftType.DRAFT, + AgentConfigDraftType.DEBUG_BUILD, + } + + def test_agent_app_debug_conversation_message_count(): session = FakeSession(scalar=[3]) @@ -2794,6 +2833,7 @@ def test_agent_app_debug_conversation_requires_app_binding(): AgentRosterService(FakeSession())._get_or_create_agent_app_debug_conversation( agent=agent, account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) @@ -2840,7 +2880,9 @@ def test_load_or_create_agent_app_debug_conversations_supports_runtime_backed_ag assert result["agent-1"] assert result["agent-3"] assert fake_session.commits == 1 - assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 2 + mappings = [value for value in fake_session.added if isinstance(value, AgentDebugConversation)] + assert len(mappings) == 2 + assert all(mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD for mapping in mappings) def test_agent_app_visible_versions_exclude_draft_saves(): @@ -3276,6 +3318,7 @@ class TestAgentAppBackingAgent: assert mappings[0].agent_id == "agent-1" assert mappings[0].app_id == "app-1" assert mappings[0].account_id == "account-1" + assert mappings[0].draft_type == AgentConfigDraftType.DEBUG_BUILD assert mappings[0].conversation_id == conversation_id assert session.deleted == [] assert session.commits == 1 @@ -3361,8 +3404,10 @@ class TestAgentAppBackingAgent: payload = cleanup_delay.call_args.args[0] assert payload["metadata"]["conversation_id"] == "old-conversation" assert payload["metadata"]["agent_id"] == "agent-9" + assert payload["metadata"]["draft_type"] == "debug_build" assert ( - payload["idempotency_key"] == "tenant-1:agent-1:account-1:old-conversation:debug-session-cleanup:" + payload["idempotency_key"] + == "tenant-1:agent-1:account-1:debug_build:old-conversation:debug-session-cleanup:" "agent-9:snap-9:run-old" ) cleanup_store.mark_cleaned.assert_called_once_with( diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index 2cc590c433d..12b58ef4188 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -143,6 +143,7 @@ import { zPostAgentByAgentIdCopyBody, zPostAgentByAgentIdCopyPath, zPostAgentByAgentIdCopyResponse, + zPostAgentByAgentIdDebugConversationRefreshBody, zPostAgentByAgentIdDebugConversationRefreshPath, zPostAgentByAgentIdDebugConversationRefreshResponse, zPostAgentByAgentIdFeaturesBody, @@ -852,7 +853,12 @@ export const post12 = oc path: '/agent/{agent_id}/debug-conversation/refresh', tags: ['console'], }) - .input(z.object({ params: zPostAgentByAgentIdDebugConversationRefreshPath })) + .input( + z.object({ + body: zPostAgentByAgentIdDebugConversationRefreshBody.optional(), + params: zPostAgentByAgentIdDebugConversationRefreshPath, + }), + ) .output(zPostAgentByAgentIdDebugConversationRefreshResponse) export const refresh = { diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 2a5e0dcb3b2..98fda2ccab9 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -282,6 +282,10 @@ export type AgentAppCopyPayload = { role?: string | null } +export type AgentDebugConversationRefreshPayload = { + draft_type?: AgentConfigDraftType +} + export type AgentDebugConversationRefreshResponse = { debug_conversation_has_messages?: boolean debug_conversation_id: string @@ -818,6 +822,8 @@ export type AgentConfigSkillMarkdownResponse = { truncated: boolean } +export type AgentConfigDraftType = 'debug_build' | 'draft' + export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1221,8 +1227,6 @@ export type AgentSoulToolsConfig = { dify_tools?: Array } -export type AgentConfigDraftType = 'debug_build' | 'draft' - export type DeclaredOutputConfig = { array_item?: DeclaredArrayItem | null check?: DeclaredOutputCheckConfig | null @@ -2753,7 +2757,7 @@ export type PostAgentByAgentIdCopyResponse = PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] export type PostAgentByAgentIdDebugConversationRefreshData = { - body?: never + body?: AgentDebugConversationRefreshPayload path: { agent_id: string } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 085f6551390..4146d261ffc 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -651,6 +651,45 @@ export const zAgentConfigSkillInspectResponse = z.object({ warnings: z.array(z.string()).optional(), }) +/** + * AgentConfigDraftType + * + * Editable Agent Soul draft workspace type. + */ +export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) + +/** + * AgentDebugConversationRefreshPayload + */ +export const zAgentDebugConversationRefreshPayload = z.object({ + draft_type: zAgentConfigDraftType.optional().default('debug_build'), +}) + +/** + * AgentConfigDraftSummaryResponse + */ +export const zAgentConfigDraftSummaryResponse = z.object({ + account_id: z.string().nullish(), + agent_id: z.string(), + base_snapshot_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + draft_type: zAgentConfigDraftType, + id: z.string(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AgentPublishResponse + */ +export const zAgentPublishResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + active_config_snapshot_id: z.string(), + draft: zAgentConfigDraftSummaryResponse.nullish(), + result: z.string(), +}) + /** * AgentDriveItemResponse */ @@ -1240,38 +1279,6 @@ export const zAgentSoulPromptConfig = z.object({ system_prompt: z.string().optional().default(''), }) -/** - * AgentConfigDraftType - * - * Editable Agent Soul draft workspace type. - */ -export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) - -/** - * AgentConfigDraftSummaryResponse - */ -export const zAgentConfigDraftSummaryResponse = z.object({ - account_id: z.string().nullish(), - agent_id: z.string(), - base_snapshot_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - draft_type: zAgentConfigDraftType, - id: z.string(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), -}) - -/** - * AgentPublishResponse - */ -export const zAgentPublishResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - active_config_snapshot_id: z.string(), - draft: zAgentConfigDraftSummaryResponse.nullish(), - result: z.string(), -}) - /** * AgentHumanContactConfig */ @@ -3257,6 +3264,8 @@ export const zPostAgentByAgentIdCopyPath = z.object({ */ export const zPostAgentByAgentIdCopyResponse = zAgentAppDetailWithSite +export const zPostAgentByAgentIdDebugConversationRefreshBody = zAgentDebugConversationRefreshPayload + export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({ agent_id: z.uuid(), }) From 8ee3a7eabc666fc4d2c33839ce5626931c73648c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 18:19:25 +0900 Subject: [PATCH 35/63] test: use sqlite3 session in test_rag_pipeline_workflow (#38676) --- .../test_rag_pipeline_workflow.py | 96 +++++++++---------- 1 file changed, 47 insertions(+), 49 deletions(-) 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 e344a4c8bab..c0f9a902a56 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 @@ -1,3 +1,9 @@ +"""RAG pipeline workflow controller serialization tests. + +Handlers that own transactions run against real SQLite sessions so response +DTOs must be materialized before those transaction contexts close. +""" + from __future__ import annotations from datetime import datetime @@ -7,6 +13,8 @@ from unittest.mock import PropertyMock, patch import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from controllers.console.datasets.rag_pipeline import rag_pipeline_workflow as module from models.account import Account, TenantAccountRole @@ -73,90 +81,80 @@ def test_draft_rag_pipeline_workflow_get_serializes_response_model(monkeypatch: def test_published_rag_pipeline_workflows_serialize_items_before_session_closes( - app, monkeypatch: pytest.MonkeyPatch + app, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine ) -> None: api = module.PublishedAllRagPipelineApi() handler = unwrap_all(api.get) - session_state = {"open": False} - - class _SessionContext: - def __enter__(self): - session_state["open"] = True - return object() - - def __exit__(self, exc_type, exc, tb): - session_state["open"] = False - return False - - class _SessionMaker: - def begin(self): - return _SessionContext() + session_state: dict[str, Session] = {} base_workflow = _make_workflow() class _Workflow: def __getattr__(self, name: str): - assert session_state["open"] is True + assert session_state["session"].in_transaction() is True return getattr(base_workflow, name) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object(), session=lambda: object())) - monkeypatch.setattr(module, "sessionmaker", lambda *_args, **_kwargs: _SessionMaker()) + def _get_all_published_workflow(**kwargs): + session_state["session"] = kwargs["session"] + return [_Workflow()], False + monkeypatch.setattr( module, "RagPipelineService", - lambda *_args, **_kwargs: SimpleNamespace(get_all_published_workflow=lambda **_kwargs: ([_Workflow()], False)), + lambda *_args, **_kwargs: SimpleNamespace(get_all_published_workflow=_get_all_published_workflow), ) - with app.test_request_context( - "/rag/pipelines/pipeline-1/workflows", - method="GET", - query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"}, - ): - response = handler(api, _account(), pipeline=_pipeline()) + with Session(sqlite_engine) as request_session: + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine, session=lambda: request_session)) + with app.test_request_context( + "/rag/pipelines/pipeline-1/workflows", + method="GET", + query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"}, + ): + response = handler(api, _account(), pipeline=_pipeline()) + assert session_state["session"].in_transaction() is False assert response["items"][0]["id"] == "workflow-1" assert response["page"] == 1 assert response["limit"] == 10 assert response["has_more"] is False -def test_rag_pipeline_workflow_patch_serializes_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_rag_pipeline_workflow_patch_serializes_response_model( + app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine +) -> None: workflow = _make_workflow(marked_name="Updated release") + captured_session: dict[str, Session] = {} - class _SessionContext: - def __enter__(self): - return object() + def _update_workflow(**kwargs): + captured_session["session"] = kwargs["session"] + assert kwargs["session"].in_transaction() is True + return workflow - def __exit__(self, exc_type, exc, tb): - return False - - class _SessionMaker: - def begin(self): - return _SessionContext() - - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object(), session=lambda: object())) - monkeypatch.setattr(module, "sessionmaker", lambda *_args, **_kwargs: _SessionMaker()) monkeypatch.setattr( module, "RagPipelineService", - lambda *_args, **_kwargs: SimpleNamespace(update_workflow=lambda **_kwargs: workflow), + lambda *_args, **_kwargs: SimpleNamespace(update_workflow=_update_workflow), ) payload: dict[str, object] = {"marked_name": "Updated release"} api = module.RagPipelineByIdApi() handler = unwrap_all(api.patch) - with ( - app.test_request_context("/rag/pipelines/pipeline-1/workflows/workflow-1", method="PATCH", json=payload), - patch.object(type(module.console_ns), "payload", new_callable=PropertyMock, return_value=payload), - ): - response = handler( - api, - _account(), - pipeline=_pipeline(), - workflow_id="workflow-1", - ) + with Session(sqlite_engine) as request_session: + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine, session=lambda: request_session)) + with ( + app.test_request_context("/rag/pipelines/pipeline-1/workflows/workflow-1", method="PATCH", json=payload), + patch.object(type(module.console_ns), "payload", new_callable=PropertyMock, return_value=payload), + ): + response = handler( + api, + _account(), + pipeline=_pipeline(), + workflow_id="workflow-1", + ) + assert captured_session["session"].in_transaction() is False assert response["id"] == "workflow-1" assert response["marked_name"] == "Updated release" assert response["hash"] == "hash-1" From 9b2aa8b216ed35416686676b35fc1f8ec599aed3 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 18:20:21 +0900 Subject: [PATCH 36/63] test: use sqlite3 session in test_snippet_workflow (#38677) --- .../console/snippets/test_snippet_workflow.py | 129 +++++++++--------- 1 file changed, 64 insertions(+), 65 deletions(-) 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 98b538800ac..8a542ef269e 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 @@ -8,6 +8,8 @@ from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import HTTPException, NotFound from controllers.console.snippets import snippet_workflow as snippet_workflow_module @@ -36,7 +38,9 @@ def _snippet(**overrides) -> CustomizedSnippet: @pytest.fixture(autouse=True) -def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: +def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: + snippet_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + def factory(): try: return snippet_workflow_module.SnippetService(snippet_workflow_module._snippet_session_maker()) @@ -44,7 +48,7 @@ def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: return snippet_workflow_module.SnippetService() monkeypatch.setattr(snippet_workflow_module, "_snippet_service", factory) - monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=Mock())) + monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", lambda: snippet_session_maker) def test_get_snippet_requires_snippet_id(app): @@ -150,28 +154,28 @@ 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_post_returns_400_when_publish_fails(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) +def test_published_workflow_post_returns_400_when_publish_fails( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +) -> None: user = _account("account-1") snippet = _snippet() - merged_snippet = _snippet() - session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock()) + sqlite_session.add(snippet) + sqlite_session.commit() - class SessionContext: - def __init__(self, engine): - self.engine = engine + def fail_publish(*, session: Session, snippet: CustomizedSnippet, account: Account): + snippet.name = "Uncommitted name" + session.add(snippet) + raise ValueError("No valid workflow found.") - def __enter__(self): - return session - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) - monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=sqlite_engine)) monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(publish_workflow=Mock(side_effect=ValueError("No valid workflow found."))), + lambda: SimpleNamespace(publish_workflow=Mock(side_effect=fail_publish)), ) api = snippet_workflow_module.SnippetPublishedWorkflowApi() @@ -182,7 +186,8 @@ def test_published_workflow_post_returns_400_when_publish_fails(app: Flask, monk assert status_code == 400 assert response == {"message": "No valid workflow found."} - session.commit.assert_not_called() + sqlite_session.refresh(snippet) + assert snippet.name == "Snippet" def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -203,7 +208,11 @@ def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pyt get_default_block_configs.assert_called_once() -def test_list_published_snippet_workflows_includes_input_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_published_snippet_workflows_includes_input_fields( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, +) -> None: workflow = SimpleNamespace( id="workflow-1", graph_dict={"nodes": [], "edges": []}, @@ -224,18 +233,7 @@ def test_list_published_snippet_workflows_includes_input_fields(app: Flask, monk input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) - class SessionContext: - def __init__(self, engine): - self.engine = engine - - def __enter__(self): - return Mock() - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) - monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=sqlite_engine)) monkeypatch.setattr( snippet_workflow_module, "SnippetService", @@ -364,8 +362,11 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra assert exc.value.description == "invalid snippet workflow graph" +@pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) def test_update_published_snippet_workflow_returns_updated_workflow( - app: Flask, monkeypatch: pytest.MonkeyPatch + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, ) -> None: workflow = SimpleNamespace( id="workflow-1", @@ -387,21 +388,15 @@ def test_update_published_snippet_workflow_returns_updated_workflow( user = _account("account-1") input_fields = [{"variable": "query", "type": "text"}] snippet = _snippet(input_fields=json.dumps(input_fields)) - session = SimpleNamespace() - update_workflow = Mock(return_value=workflow) + sqlite_session.add(snippet) + sqlite_session.commit() - class TransactionContext: - def __enter__(self): - return session + def update_persisted_snippet(*, session: Session, snippet: CustomizedSnippet, **_kwargs): + merged_snippet = session.merge(snippet) + merged_snippet.description = "Updated in transaction" + return workflow - def __exit__(self, exc_type, exc, tb): - return False - - class SessionMaker: - def begin(self): - return TransactionContext() - - monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker())) + update_workflow = Mock(side_effect=update_persisted_snippet) monkeypatch.setattr( snippet_workflow_module, "SnippetService", @@ -418,16 +413,18 @@ def test_update_published_snippet_workflow_returns_updated_workflow( ): response = handler(api, user, snippet, workflow_id="workflow-1") - update_workflow.assert_called_once_with( - session=session, - snippet=snippet, - workflow_id="workflow-1", - account=user, - data={"marked_name": "v1", "marked_comment": "first version"}, - ) + update_workflow.assert_called_once() + update_call = update_workflow.call_args.kwargs + assert isinstance(update_call["session"], Session) + assert update_call["snippet"] is snippet + assert update_call["workflow_id"] == "workflow-1" + assert update_call["account"] is user + assert update_call["data"] == {"marked_name": "v1", "marked_comment": "first version"} assert response["marked_name"] == "v1" assert response["marked_comment"] == "first version" assert response["input_fields"] == input_fields + sqlite_session.refresh(snippet) + assert snippet.description == "Updated in transaction" def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask) -> None: @@ -441,26 +438,25 @@ def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask assert response == {"message": "No valid fields to update"} -def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("sqlite_session", [(CustomizedSnippet,)], indirect=True) +def test_update_published_snippet_workflow_raises_not_found( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, +) -> None: user = _account("account-1") snippet = _snippet() + sqlite_session.add(snippet) + sqlite_session.commit() - class TransactionContext: - def __enter__(self): - return SimpleNamespace() + def update_missing_workflow(*, session: Session, snippet: CustomizedSnippet, **_kwargs): + merged_snippet = session.merge(snippet) + merged_snippet.name = "Rolled back name" - def __exit__(self, exc_type, exc, tb): - return False - - class SessionMaker: - def begin(self): - return TransactionContext() - - monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker())) monkeypatch.setattr( snippet_workflow_module, "SnippetService", - lambda: SimpleNamespace(update_workflow=Mock(return_value=None)), + lambda: SimpleNamespace(update_workflow=Mock(side_effect=update_missing_workflow)), ) api = snippet_workflow_module.SnippetWorkflowByIdApi() @@ -474,6 +470,9 @@ def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypa with pytest.raises(NotFound, match="Workflow not found"): handler(api, user, snippet, workflow_id="missing-workflow") + sqlite_session.refresh(snippet) + assert snippet.name == "Snippet" + def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() From de077305480404d237d3a62c8da87b9987f26147 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 22 Jul 2026 18:20:51 +0900 Subject: [PATCH 37/63] test: use sqlite3 session in test_snippet_workflow_draft_variable (#38678) --- .../test_snippet_workflow_draft_variable.py | 294 ++++++++++++------ 1 file changed, 192 insertions(+), 102 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py index 03a6fdb0d60..0a9382f6d59 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py @@ -1,15 +1,29 @@ +from collections.abc import Iterator from inspect import unwrap from types import SimpleNamespace from unittest.mock import Mock import pytest from flask import Flask +from sqlalchemy import event, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session, sessionmaker from controllers.console.snippets import snippet_workflow_draft_variable as module -from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from graphon.variables import StringSegment from models.account import Account, AccountStatus +from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile from services.workflow_draft_variable_service import WorkflowDraftVariableList +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize( + "sqlite_session", + [(WorkflowDraftVariable, WorkflowDraftVariableFile)], + indirect=True, + ), +] + def _make_account() -> Account: account = Account( @@ -21,8 +35,31 @@ def _make_account() -> Account: return account +def _make_node_variable( + variable_id: str, + *, + app_id: str = "snippet-1", + user_id: str = "user-1", + node_id: str = "llm-1", + name: str | None = None, + node_execution_id: str | None = "execution-1", +) -> WorkflowDraftVariable: + """Create a valid node variable for persisted controller tests.""" + variable = WorkflowDraftVariable.new_node_variable( + app_id=app_id, + user_id=user_id, + node_id=node_id, + name=name or variable_id, + value=StringSegment(value=f"value-{variable_id}"), + node_execution_id=node_execution_id or "execution-1", + ) + variable.id = variable_id + variable.node_execution_id = node_execution_id + return variable + + @pytest.fixture(autouse=True) -def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch): +def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: def factory(): service_factory = module.SnippetService if isinstance(service_factory, type): @@ -33,33 +70,69 @@ def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch): @pytest.fixture -def app(): +def app() -> Flask: app = Flask("test_snippet_workflow_draft_variable") app.config["TESTING"] = True return app -def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable(): - variable = SimpleNamespace(node_id=SYSTEM_VARIABLE_NODE_ID) +@pytest.fixture +def controller_sessions( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, +) -> Iterator[scoped_session[Session]]: + """Bind both controller session styles to the isolated SQLite engine.""" + sessions = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine, session=sessions)) + try: + yield sessions + finally: + sessions.remove() + + +def _persist_variables(sqlite_session: Session, *variables: WorkflowDraftVariable) -> None: + sqlite_session.add_all(variables) + sqlite_session.commit() + + +def _variable_ids(sqlite_engine: Engine) -> set[str]: + with Session(sqlite_engine) as session: + return set(session.scalars(select(WorkflowDraftVariable.id))) + + +def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable() -> None: + variable = WorkflowDraftVariable.new_sys_variable( + app_id="snippet-1", + user_id="user-1", + name="query", + value=StringSegment(value="query"), + node_execution_id="execution-1", + editable=True, + ) with pytest.raises(module.NotFoundError, match="variable not found"): module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable(): - variable = SimpleNamespace(node_id=CONVERSATION_VARIABLE_NODE_ID) +def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable() -> None: + variable = WorkflowDraftVariable.new_conversation_variable( + app_id="snippet-1", + user_id="user-1", + name="conversation-name", + value=StringSegment(value="value"), + ) with pytest.raises(module.NotFoundError, match="variable not found"): module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable(): - variable = SimpleNamespace(node_id="llm-1") +def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable() -> None: + variable = _make_node_variable("var-1") module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_conversation_variables_returns_empty_list(app: Flask): +def test_conversation_variables_returns_empty_list(app: Flask) -> None: api = module.SnippetConversationVariableCollectionApi() handler = unwrap(api.get) @@ -69,7 +142,7 @@ def test_conversation_variables_returns_empty_list(app: Flask): assert result == WorkflowDraftVariableList(variables=[]) -def test_system_variables_returns_empty_list(app: Flask): +def test_system_variables_returns_empty_list(app: Flask) -> None: api = module.SnippetSystemVariableCollectionApi() handler = unwrap(api.get) @@ -79,12 +152,17 @@ def test_system_variables_returns_empty_list(app: Flask): assert result == WorkflowDraftVariableList(variables=[]) -def test_delete_variable_collection_deletes_current_user_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - draft_var_service = SimpleNamespace(delete_user_workflow_variables=Mock()) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) +def test_delete_variable_collection_deletes_only_current_user_variables( + app: Flask, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + matching = _make_node_variable("matching", name="matching") + matching_second = _make_node_variable("matching-second", node_id="tool-1", name="matching-second") + other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") + other_snippet = _make_node_variable("other-snippet", app_id="snippet-2", name="other-snippet") + _persist_variables(sqlite_session, matching, matching_second, other_user, other_snippet) api = module.SnippetWorkflowVariableCollectionApi() handler = unwrap(api.delete) @@ -92,11 +170,14 @@ def test_delete_variable_collection_deletes_current_user_variables(app: Flask, m response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) assert response.status_code == 204 - draft_var_service.delete_user_workflow_variables.assert_called_once_with("snippet-1", user_id="user-1") - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == {other_user.id, other_snippet.id} + assert not controller_sessions().in_transaction() -def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): +def test_variable_collection_get_raises_when_draft_workflow_missing( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr( module, "SnippetService", @@ -111,47 +192,37 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask, handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) -def test_node_variable_collection_get_lists_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - variables = WorkflowDraftVariableList(variables=[SimpleNamespace(id="var-1")]) - list_node_variables = Mock(return_value=variables) - - class SessionContext: - def __init__(self, bind, expire_on_commit=False): - self.bind = bind - self.expire_on_commit = expire_on_commit - - def __enter__(self): - return SimpleNamespace() - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr(module, "Session", SessionContext) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr( - module, - "WorkflowDraftVariableService", - Mock(return_value=SimpleNamespace(list_node_variables=list_node_variables)), - ) - +def test_node_variable_collection_get_lists_persisted_node_variables( + app: Flask, + sqlite_session: Session, + controller_sessions: scoped_session[Session], +) -> None: + matching = _make_node_variable("matching", name="matching") + other_node = _make_node_variable("other-node", node_id="tool-1", name="other-node") + other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") + other_snippet = _make_node_variable("other-snippet", app_id="snippet-2", name="other-snippet") + _persist_variables(sqlite_session, matching, other_node, other_user, other_snippet) api = module.SnippetNodeVariableCollectionApi() handler = unwrap(api.get) with app.test_request_context("/"): result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") - assert result is variables - list_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") + assert [variable.id for variable in result.variables] == [matching.id] + assert controller_sessions().get_bind() is not None -def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): - delete_node_variables = Mock() - draft_var_service = SimpleNamespace(delete_node_variables=delete_node_variables) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - +def test_node_variable_collection_delete_deletes_only_requested_node_variables( + app: Flask, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + matching = _make_node_variable("matching", name="matching") + matching_second = _make_node_variable("matching-second", name="matching-second") + other_node = _make_node_variable("other-node", node_id="tool-1", name="other-node") + other_user = _make_node_variable("other-user", user_id="user-2", name="other-user") + _persist_variables(sqlite_session, matching, matching_second, other_node, other_user) api = module.SnippetNodeVariableCollectionApi() handler = unwrap(api.delete) @@ -159,83 +230,102 @@ def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monk response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") assert response.status_code == 204 - delete_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == {other_node.id, other_user.id} + assert not controller_sessions().in_transaction() -def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), update_variable=Mock()) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) +def test_variable_patch_returns_persisted_variable_without_committing_when_no_changes( + app: Flask, + sqlite_session: Session, + controller_sessions: scoped_session[Session], +) -> None: + variable = _make_node_variable("var-1") + _persist_variables(sqlite_session, variable) + session = controller_sessions() + commits: list[bool] = [] + def record_commit(_session: Session) -> None: + commits.append(True) + + event.listen(session, "after_commit", record_commit) api = module.SnippetVariableApi() handler = unwrap(api.patch) + try: + with app.test_request_context("/", method="PATCH", json={}): + result = handler( + api, + _make_account(), + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + variable_id="var-1", + ) + finally: + event.remove(session, "after_commit", record_commit) - with app.test_request_context("/", method="PATCH", json={}): - result = handler( - api, - _make_account(), - snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), - variable_id="var-1", - ) - - assert result is variable - draft_var_service.update_variable.assert_not_called() - db_session.commit.assert_not_called() + assert result.id == variable.id + assert result.app_id == "snippet-1" + assert commits == [] + assert session.in_transaction() -def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - delete_variable = Mock() - draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), delete_variable=delete_variable) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) - +def test_variable_delete_deletes_persisted_variable( + app: Flask, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + variable = _make_node_variable("var-1") + retained = _make_node_variable("var-2", name="retained") + _persist_variables(sqlite_session, variable, retained) api = module.SnippetVariableApi() handler = unwrap(api.delete) with app.test_request_context("/", method="DELETE"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + response = handler( + api, + _make_account(), + snippet=SimpleNamespace(id="snippet-1"), + variable_id=variable.id, + ) assert response.status_code == 204 - delete_variable.assert_called_once_with(variable) - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == {retained.id} + assert not controller_sessions().in_transaction() -def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask, monkeypatch: pytest.MonkeyPatch): - variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") - draft_workflow = SimpleNamespace(id="workflow-1") - draft_var_service = SimpleNamespace( - get_variable=Mock(return_value=variable), - reset_variable=Mock(return_value=None), - ) - db_session = Mock() - db_session.return_value = SimpleNamespace() - monkeypatch.setattr(module.db, "session", db_session) - monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) +def test_variable_reset_deletes_variable_without_node_execution( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + controller_sessions: scoped_session[Session], +) -> None: + variable = _make_node_variable("var-1", node_execution_id=None) + _persist_variables(sqlite_session, variable) monkeypatch.setattr( module, "SnippetService", - Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))), + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=SimpleNamespace(id="workflow-1")))), ) - api = module.SnippetVariableResetApi() handler = unwrap(api.put) with app.test_request_context("/", method="PUT"): - response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + response = handler( + api, + _make_account(), + snippet=SimpleNamespace(id="snippet-1"), + variable_id=variable.id, + ) assert response.status_code == 204 - draft_var_service.reset_variable.assert_called_once_with(draft_workflow, variable) - db_session.commit.assert_called_once() + assert _variable_ids(sqlite_engine) == set() + assert not controller_sessions().in_transaction() -def test_environment_variables_returns_workflow_environment_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): +def test_environment_variables_returns_workflow_environment_variables( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: env_var = SimpleNamespace( id="env-1", name="API_KEY", From 62db37c403f96b8975d02d9c0cda326560d84f36 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:40:47 +0800 Subject: [PATCH 38/63] fix(web): improve DSL export feedback (#39409) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/dify-ui/src/toast/index.stories.tsx | 91 +++--- packages/dify-ui/src/toast/index.tsx | 20 +- .../__tests__/app-info-detail-panel.spec.tsx | 25 +- .../__tests__/app-info-modals.spec.tsx | 41 +-- .../__tests__/use-app-info-actions.spec.ts | 108 +++---- .../app-info/app-info-detail-panel.tsx | 5 +- .../app-sidebar/app-info/app-info-modals.tsx | 31 +- .../app-sidebar/app-info/app-operations.tsx | 13 +- .../components/app-sidebar/app-info/index.tsx | 3 + .../app-info/use-app-info-actions.ts | 64 ++--- .../app/__tests__/use-export-app-dsl.spec.tsx | 266 ++++++++++++++++++ web/app/components/app/use-export-app-dsl.ts | 148 ++++++++++ .../apps/__tests__/app-card.spec.tsx | 110 ++++---- web/app/components/apps/app-card.tsx | 162 ++++++----- .../workflow/dsl-export-confirm-modal.tsx | 2 +- .../__tests__/navigation.spec.tsx | 30 +- .../agent-v2/agent-detail/sidebar-actions.tsx | 24 +- .../__tests__/agent-roster-list.spec.tsx | 45 +-- .../roster/components/agent-roster-list.tsx | 24 +- 19 files changed, 776 insertions(+), 436 deletions(-) create mode 100644 web/app/components/app/__tests__/use-export-app-dsl.spec.tsx create mode 100644 web/app/components/app/use-export-app-dsl.ts diff --git a/packages/dify-ui/src/toast/index.stories.tsx b/packages/dify-ui/src/toast/index.stories.tsx index 301252bbc38..8997874324f 100644 --- a/packages/dify-ui/src/toast/index.stories.tsx +++ b/packages/dify-ui/src/toast/index.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' import { expect, within } from 'storybook/test' import { toast, ToastHost } from '.' +import { Button } from '../button' const longToastTitle = 'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed' @@ -155,58 +156,62 @@ const StackExamples = () => { } const PromiseExamples = () => { - const createPromiseToast = () => { - const request = new Promise((resolve) => { - window.setTimeout(() => resolve('The deployment is now available in production.'), 1400) + const [pendingExample, setPendingExample] = React.useState<'success' | 'error' | null>(null) + + const exportDsl = async (outcome: 'success' | 'error') => { + if (pendingExample) return + + setPendingExample(outcome) + const request = new Promise((resolve, reject) => { + window.setTimeout(() => { + if (outcome === 'success') resolve('customer-support-agent.yml') + else reject(new Error('The DSL could not be generated.')) + }, 1400) }) - void toast.promise(request, { - loading: { - type: 'info', - title: 'Deploying workflow', - description: 'Provisioning runtime and publishing the latest version.', - }, - success: (result) => ({ - type: 'success', - title: 'Deployment complete', - description: result, - }), - error: () => ({ - type: 'error', - title: 'Deployment failed', - description: 'The release could not be completed.', - }), - }) - } + await toast + .promise(request, { + loading: { + title: 'Preparing DSL export', + description: 'Collecting the app configuration and generating a YAML file.', + }, + success: (fileName) => ({ + title: 'Download started', + description: `${fileName} was sent to your browser.`, + timeout: 3000, + }), + error: () => ({ + title: 'Export failed', + description: 'The DSL could not be generated. Try again.', + }), + }) + .catch(() => undefined) - const createRejectingPromiseToast = () => { - const request = new Promise((_, reject) => { - window.setTimeout(() => reject(new Error('intentional story failure')), 1200) - }) - - void toast.promise(request, { - loading: 'Validating model credentials…', - success: 'Credentials verified', - error: () => ({ - type: 'error', - title: 'Credentials rejected', - description: 'The model provider returned an authentication error.', - }), - }) + setPendingExample(null) } return ( - - + + ) } diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx index 9aad5890c17..3fa8d3b9f04 100644 --- a/packages/dify-ui/src/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -16,6 +16,11 @@ type ToastToneStyle = { } const TOAST_TONE_STYLES = { + loading: { + iconClassName: 'i-ri-loader-2-line animate-spin text-text-accent motion-reduce:animate-none', + gradientClassName: + 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent', + }, success: { iconClassName: 'i-ri-checkbox-circle-fill text-text-success', gradientClassName: @@ -41,7 +46,8 @@ const TOAST_TONE_STYLES = { const toastCloseLabel = 'Close notification' const toastViewportLabel = 'Notifications' -type ToastType = keyof typeof TOAST_TONE_STYLES +type ToastRenderType = keyof typeof TOAST_TONE_STYLES +type ToastType = Exclude type ToastAddOptions = Omit< ToastManagerAddOptions, @@ -96,12 +102,12 @@ type ToastApi = { const toastManager = BaseToast.createToastManager() -function isToastType(type: string): type is ToastType { +function isToastRenderType(type: string): type is ToastRenderType { return Object.prototype.hasOwnProperty.call(TOAST_TONE_STYLES, type) } -function getToastType(type?: string): ToastType | undefined { - return type && isToastType(type) ? type : undefined +function getToastRenderType(type?: string): ToastRenderType | undefined { + return type && isToastRenderType(type) ? type : undefined } function addToast(options: ToastAddOptions) { @@ -145,19 +151,19 @@ export const toast: ToastApi = Object.assign(showToast, { promise: promiseToast, }) -function ToastIcon({ type }: { type?: ToastType }) { +function ToastIcon({ type }: { type?: ToastRenderType }) { return type ? (