diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 59e8d856975..9906827433a 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -72,6 +72,7 @@ jobs: - 'docker/volumes/sandbox/conf/**' cli: - 'cli/**' + - 'packages/contracts/**' - 'packages/tsconfig/**' - 'package.json' - 'pnpm-lock.yaml' @@ -105,6 +106,7 @@ jobs: - 'docker/docker-compose.middleware.yaml' - 'docker/envs/middleware.env.example' - '.github/workflows/web-e2e.yml' + - '.github/workflows/main-ci.yml' - '.github/actions/setup-web/**' vdb: - 'api/core/rag/datasource/**' diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 43108462a09..22d20584e61 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -19,6 +19,7 @@ jobs: test: name: Web Full-Stack E2E runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 120 defaults: run: shell: bash @@ -58,10 +59,57 @@ jobs: E2E_ADMIN_EMAIL: e2e-admin@example.com E2E_ADMIN_NAME: E2E Admin E2E_ADMIN_PASSWORD: E2eAdmin12345 + E2E_CUCUMBER_REPORT_PROFILE: core E2E_FORCE_WEB_BUILD: "1" E2E_INIT_PASSWORD: E2eInit12345 run: vp run e2e:full + - name: Preserve Chromium E2E report and logs + if: ${{ !cancelled() }} + run: | + if [[ -d e2e/cucumber-report ]]; then + mv e2e/cucumber-report e2e/cucumber-report-non-external + fi + if [[ -d e2e/.logs ]]; then + mv e2e/.logs e2e/.logs-non-external + fi + + - name: Run WebKit keyboard and browser smoke tests + working-directory: ./e2e + env: + E2E_ADMIN_EMAIL: e2e-admin@example.com + E2E_ADMIN_NAME: E2E Admin + E2E_ADMIN_PASSWORD: E2eAdmin12345 + E2E_BROWSER: webkit + E2E_CUCUMBER_REPORT_PROFILE: webkit-browser-smoke + E2E_INIT_PASSWORD: E2eInit12345 + run: | + teardown_webkit_smoke() { + local run_status=$? + trap - EXIT + if ! vp run e2e:middleware:down; then + echo "::error title=E2E teardown failed::WebKit smoke middleware did not shut down cleanly." + if [[ "$run_status" -eq 0 ]]; then + run_status=1 + fi + fi + exit "$run_status" + } + + trap teardown_webkit_smoke EXIT + vp run e2e:middleware:up + vp run e2e -- --tags '@browser-smoke' + + - name: Preserve WebKit E2E report and logs + if: ${{ !cancelled() }} + run: | + if [[ -d e2e/cucumber-report ]]; then + mv e2e/cucumber-report e2e/cucumber-report-webkit + fi + if [[ -d e2e/.logs ]]; then + mv e2e/.logs e2e/.logs-webkit + fi + - name: Run external runtime E2E tests if: ${{ inputs.run-external-runtime }} working-directory: ./e2e @@ -72,8 +120,8 @@ jobs: E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }} E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }} E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }} + E2E_CUCUMBER_REPORT_PROFILE: external E2E_EXTERNAL_RUNTIME_SEED_SPECS: ${{ vars.E2E_EXTERNAL_RUNTIME_SEED_SPECS }} - E2E_EXTERNAL_RUNTIME_TAGS: ${{ vars.E2E_EXTERNAL_RUNTIME_TAGS }} E2E_FORCE_WEB_BUILD: "1" E2E_INIT_PASSWORD: E2eInit12345 E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }} @@ -102,7 +150,19 @@ jobs: mv .logs .logs-non-external fi - trap 'vp run e2e:middleware:down' EXIT + teardown_external_runtime() { + local run_status=$? + trap - EXIT + if ! vp run e2e:middleware:down; then + echo "::error title=E2E teardown failed::External runtime middleware did not shut down cleanly." + if [[ "$run_status" -eq 0 ]]; then + run_status=1 + fi + fi + exit "$run_status" + } + + trap teardown_external_runtime EXIT vp run e2e:middleware:up vp run e2e:external:prepare vp run e2e:external @@ -115,6 +175,7 @@ jobs: path: | e2e/cucumber-report e2e/cucumber-report-non-external + e2e/cucumber-report-webkit retention-days: 7 - name: Upload E2E logs @@ -125,5 +186,15 @@ jobs: path: | e2e/.logs/*.log e2e/.logs-non-external/*.log + e2e/.logs-webkit/*.log include-hidden-files: true retention-days: 7 + + - name: Upload E2E seed report + if: ${{ !cancelled() && inputs.run-external-runtime }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-seed-report + path: e2e/seed-report + if-no-files-found: ignore + retention-days: 7 diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 7ce6a103046..c4428b716bf 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -188,6 +188,14 @@ def validate_config_skill_name(name: str) -> str: return normalized +def _normalize_legacy_missing_asset_file_id(value: Any) -> Any: + """Canonicalize the null placeholder emitted by early portable Agent DSLs.""" + + if isinstance(value, dict) and value.get("is_missing") is True and value.get("file_id") is None: + return {**value, "file_id": ""} + return value + + class AgentConfigFileRefConfig(BaseModel): """Stable Agent Soul reference to one config file payload.""" @@ -201,6 +209,11 @@ class AgentConfigFileRefConfig(BaseModel): hash: str | None = None mime_type: str | None = None + @model_validator(mode="before") + @classmethod + def _normalize_legacy_file_id(cls, value: Any) -> Any: + return _normalize_legacy_missing_asset_file_id(value) + @field_validator("name") @classmethod def _validate_name(cls, value: str) -> str: @@ -231,6 +244,11 @@ class AgentConfigSkillRefConfig(BaseModel): hash: str | None = None mime_type: str | None = "application/zip" + @model_validator(mode="before") + @classmethod + def _normalize_legacy_file_id(cls, value: Any) -> Any: + return _normalize_legacy_missing_asset_file_id(value) + @field_validator("name") @classmethod def _validate_name(cls, value: str) -> str: diff --git a/api/services/agent/dsl_service.py b/api/services/agent/dsl_service.py index 0200a22704f..b2ffae45dad 100644 --- a/api/services/agent/dsl_service.py +++ b/api/services/agent/dsl_service.py @@ -11,17 +11,14 @@ from __future__ import annotations import copy import json -import logging from collections.abc import Mapping from typing import Any, cast from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import event, func, select +from sqlalchemy import func, select from sqlalchemy.orm import Session -from constants.model_template import default_app_templates from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator -from events.app_event import app_was_created from graphon.enums import BuiltinNodeTypes from models import Account from models.agent import ( @@ -41,7 +38,7 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig -from models.model import App, AppMode, AppModelConfig, IconType +from models.model import App, AppModelConfig from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.dsl_entities import ( @@ -57,8 +54,6 @@ from services.agent.roster_service import AgentRosterService from services.entities.dsl_entities import DslImportWarning from services.plugin.dependencies_analysis import DependenciesAnalysisService -logger = logging.getLogger(__name__) - class AgentPackageImportResult(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) @@ -249,7 +244,7 @@ class AgentDslService: raw_packages: Mapping[str, Any], account: Account, ) -> tuple[dict[str, Any], list[DslImportWarning]]: - """Materialize packages and bindings for a Workflow or Snippet draft.""" + """Materialize every packaged Agent as a node-owned inline Agent.""" graph = copy.deepcopy(dict(portable_graph)) packages = {key: AgentPackage.model_validate(value) for key, value in raw_packages.items()} @@ -264,7 +259,6 @@ class AgentDslService: for binding in previous_bindings: self.session.delete(binding) self.session.flush() - imported_roster: dict[str, AgentPackageImportResult] = {} warnings: list[DslImportWarning] = [] for node_id, raw_node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph): @@ -280,28 +274,17 @@ class AgentDslService: raise ValueError(f"Workflow Agent node {node_id} references unknown package {package_ref!r}.") try: - binding_type = WorkflowAgentBindingType(str(raw_binding.get("binding_type"))) + WorkflowAgentBindingType(str(raw_binding.get("binding_type"))) except ValueError as exc: raise ValueError(f"Workflow Agent node {node_id} has an invalid binding type.") from exc - if binding_type == WorkflowAgentBindingType.ROSTER_AGENT: - imported = imported_roster.get(package_ref) - if imported is None: - imported = self._create_imported_roster_agent_app( - tenant_id=workflow.tenant_id, - account=account, - package=package, - package_path=f"agent_packages.{package_ref}", - ) - imported_roster[package_ref] = imported - else: - imported = self._create_imported_inline_agent( - workflow=workflow, - node_id=node_id, - account=account, - package=package, - package_path=f"agent_packages.{package_ref}", - ) + imported = self._create_imported_inline_agent( + workflow=workflow, + node_id=node_id, + account=account, + package=package, + package_path=f"agent_packages.{package_ref}", + ) node_job = WorkflowNodeJobConfig.model_validate(node_data.get(AGENT_NODE_JOB_DSL_KEY) or {}) self.session.add( @@ -311,7 +294,7 @@ class AgentDslService: workflow_id=workflow.id, workflow_version=workflow.version, node_id=node_id, - binding_type=binding_type, + binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id=imported.agent.id, current_snapshot_id=imported.snapshot.id, node_job_config=node_job, @@ -320,7 +303,7 @@ class AgentDslService: ) ) node_data["agent_binding"] = { - "binding_type": binding_type.value, + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, "agent_id": imported.agent.id, "current_snapshot_id": imported.snapshot.id, } @@ -402,43 +385,6 @@ class AgentDslService: ) return dependencies - def _create_imported_roster_agent_app( - self, - *, - tenant_id: str, - account: Account, - package: AgentPackage, - package_path: str, - ) -> AgentPackageImportResult: - metadata = package.metadata - app_template = dict(default_app_templates[AppMode.AGENT]["app"]) - app = App(**app_template) - app.name = metadata.name - app.description = metadata.description - app.mode = AppMode.AGENT - app.icon_type = self._app_icon_type(metadata.icon_type) - app.icon = metadata.icon - app.icon_background = metadata.icon_background - app.tenant_id = tenant_id - app.enable_site = True - app.enable_api = True - app.created_by = account.id - app.maintainer = account.id - app.updated_by = account.id - self.session.add(app) - self.session.flush() - app_was_created.send(app, account=account, session=self.session) - self._configure_visible_agent_app_after_commit( - tenant_id=tenant_id, - app_id=app.id, - account_id=account.id, - ) - result = self.import_agent_app_package(app=app, account=account, package=package) - result.warnings = [ - warning.model_copy(update={"path": f"{package_path}.{warning.path}"}) for warning in result.warnings - ] - return result - def _create_imported_inline_agent( self, *, @@ -654,28 +600,6 @@ class AgentDslService: ) return next(candidate for candidate in candidates if candidate not in existing) - def _configure_visible_agent_app_after_commit(self, *, tenant_id: str, app_id: str, account_id: str) -> None: - """Apply external RBAC and web-app visibility only after the DB transaction commits.""" - - def configure(_session: Session) -> None: - try: - from services.enterprise import rbac_service as enterprise_rbac_service - from services.enterprise.enterprise_service import EnterpriseService - from services.feature_service import FeatureService - - enterprise_rbac_service.try_sync_creator_access_policy_member_bindings( - tenant_id, - account_id, - enterprise_rbac_service.RBACResourceType.APP, - app_id, - ) - if FeatureService.get_system_features().webapp_auth.enabled: - EnterpriseService.WebAppAuth.update_app_access_mode(app_id, "private") - except Exception: - logger.exception("Failed to configure imported Agent App %s after commit", app_id) - - event.listen(self.session, "after_commit", configure, once=True) - def _require_agent(self, *, tenant_id: str, agent_id: str) -> Agent: agent = self.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) if agent is None: @@ -702,10 +626,6 @@ class AgentDslService: def _agent_icon_type(value: str | None) -> AgentIconType | None: return AgentIconType(value) if value else None - @staticmethod - def _app_icon_type(value: str | None) -> IconType: - return IconType(value) if value else IconType.EMOJI - def is_agent_v2_graph(graph: Mapping[str, Any]) -> bool: return any( diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 224c08384ad..32591c13312 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -11,7 +11,7 @@ import pytest import yaml from faker import Faker from flask import Flask -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from core.trigger.constants import ( @@ -22,11 +22,24 @@ from core.trigger.constants import ( from extensions.ext_redis import redis_client from graphon.enums import BuiltinNodeTypes from models import Account, App, AppMode -from models.agent import Agent, AgentConfigDraft, AgentConfigDraftType, AgentConfigSnapshot, AgentScope, AgentSource +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) from models.agent_config_entities import AgentSoulConfig from models.model import AppModelConfig, IconType +from models.workflow import Workflow, WorkflowType from services import app_dsl_service from services.account_service import AccountService, TenantService +from services.agent.dsl_entities import AGENT_PACKAGE_REF_KEY, make_portable_agent_package +from services.agent.dsl_service import AgentDslService from services.app_dsl_service import ( CHECK_DEPENDENCIES_REDIS_KEY_PREFIX, CURRENT_DSL_VERSION, @@ -952,6 +965,126 @@ class TestAppDslService: assert "model_config" in exported_data assert "dependencies" in exported_data + def test_workflow_package_import_materializes_all_agent_bindings_as_inline( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + app.mode = AppMode.WORKFLOW + workflow = Workflow.new( + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW.value, + version=Workflow.VERSION_DRAFT, + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps({}), + created_by=account.id, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + db_session_with_containers.add(workflow) + db_session_with_containers.flush() + + source_agent = Agent( + tenant_id=app.tenant_id, + name="Portable Agent", + description="Imported into each node", + role="researcher", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + created_by=account.id, + updated_by=account.id, + ) + package = make_portable_agent_package(source_agent, AgentSoulConfig(config_note="portable")) + graph = { + "nodes": [ + { + "id": "roster-node", + "data": { + "type": BuiltinNodeTypes.AGENT, + "version": "2", + "agent_binding": { + "binding_type": WorkflowAgentBindingType.ROSTER_AGENT.value, + AGENT_PACKAGE_REF_KEY: "agent_1", + }, + }, + }, + { + "id": "inline-node", + "data": { + "type": BuiltinNodeTypes.AGENT, + "version": "2", + "agent_binding": { + "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, + AGENT_PACKAGE_REF_KEY: "agent_1", + }, + }, + }, + ], + "edges": [], + } + imported_roster_count_before = db_session_with_containers.scalar( + select(func.count()) + .select_from(Agent) + .where( + Agent.tenant_id == app.tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.IMPORTED, + ) + ) + + imported_graph, warnings = AgentDslService(db_session_with_containers).import_workflow_packages( + workflow=workflow, + portable_graph=graph, + raw_packages={"agent_1": package.model_dump(mode="json")}, + account=account, + ) + db_session_with_containers.commit() + + assert warnings == [] + graph_bindings = [node["data"]["agent_binding"] for node in imported_graph["nodes"]] + assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in graph_bindings) + assert len({binding["agent_id"] for binding in graph_bindings}) == 2 + + bindings = db_session_with_containers.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == app.tenant_id, + WorkflowAgentNodeBinding.workflow_id == workflow.id, + WorkflowAgentNodeBinding.workflow_version == Workflow.VERSION_DRAFT, + ) + ).all() + assert len(bindings) == 2 + assert all(binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT for binding in bindings) + + imported_agents = db_session_with_containers.scalars( + select(Agent).where(Agent.id.in_({binding.agent_id for binding in bindings if binding.agent_id})) + ).all() + assert len(imported_agents) == 2 + assert all(agent.scope == AgentScope.WORKFLOW_ONLY for agent in imported_agents) + assert all(agent.source == AgentSource.IMPORTED for agent in imported_agents) + assert all(agent.app_id == app.id and agent.workflow_id == workflow.id for agent in imported_agents) + assert {agent.workflow_node_id for agent in imported_agents} == {"roster-node", "inline-node"} + assert all(agent.backing_app_id for agent in imported_agents) + + backing_apps = db_session_with_containers.scalars( + select(App).where(App.id.in_({agent.backing_app_id for agent in imported_agents if agent.backing_app_id})) + ).all() + assert len(backing_apps) == 2 + assert all(backing_app.mode == AppMode.AGENT for backing_app in backing_apps) + assert all(backing_app.enable_site is False and backing_app.enable_api is False for backing_app in backing_apps) + + imported_roster_count_after = db_session_with_containers.scalar( + select(func.count()) + .select_from(Agent) + .where( + Agent.tenant_id == app.tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.IMPORTED, + ) + ) + assert imported_roster_count_after == imported_roster_count_before + def test_agent_app_dsl_round_trip_creates_unpublished_imported_agent( self, db_session_with_containers: Session, mock_external_service_dependencies ): diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index e926f196192..3d305d3a8f4 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -3,6 +3,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch, sentinel import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.plugin.impl.model import PluginModelClient @@ -23,6 +25,33 @@ from graphon.nodes.llm.node import LLMNode from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.variables.segments import ArrayObjectSegment, StringSegment +from models.base import TypeBase +from models.model import AppMode, Conversation, ConversationFromSource + + +@pytest.fixture +def memory_session_maker(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> sessionmaker[Session]: + """Bind node memory lookup to an explicit SQLite session factory.""" + + TypeBase.metadata.create_all(sqlite_engine, tables=[Conversation.__table__]) + session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(node_factory.session_factory, "create_session", session_maker) + return session_maker + + +def _persist_conversation(session_maker: sessionmaker[Session]) -> None: + with session_maker.begin() as session: + session.add( + Conversation( + id="conversation-id", + app_id="app-id", + mode=AppMode.ADVANCED_CHAT, + name="Conversation", + _inputs={}, + from_source=ConversationFromSource.API, + from_end_user_id="end-user-id", + ) + ) def _assert_constructor_node_data(data, *, node_id: str, node_type: NodeType, version: str = "1") -> None: @@ -134,27 +163,7 @@ class TestFetchMemory: assert result is None - def test_returns_none_when_conversation_does_not_exist(self, monkeypatch: pytest.MonkeyPatch): - class FakeSelect: - def where(self, *_args): - return self - - class FakeSession: - def __init__(self, *_args, **_kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def scalar(self, _stmt): - return None - - monkeypatch.setattr(node_factory, "session_factory", SimpleNamespace(create_session=FakeSession)) - monkeypatch.setattr(node_factory, "select", MagicMock(return_value=FakeSelect())) - + def test_returns_none_when_conversation_does_not_exist(self, memory_session_maker: sessionmaker[Session]): result = node_factory.fetch_memory( conversation_id="conversation-id", app_id="app-id", @@ -164,30 +173,12 @@ class TestFetchMemory: assert result is None - def test_builds_token_buffer_memory_for_existing_conversation(self, monkeypatch: pytest.MonkeyPatch): - conversation = sentinel.conversation + def test_builds_token_buffer_memory_for_existing_conversation( + self, monkeypatch: pytest.MonkeyPatch, memory_session_maker: sessionmaker[Session] + ): memory = sentinel.memory - - class FakeSelect: - def where(self, *_args): - return self - - class FakeSession: - def __init__(self, *_args, **_kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def scalar(self, _stmt): - return conversation - + _persist_conversation(memory_session_maker) token_buffer_memory = MagicMock(return_value=memory) - monkeypatch.setattr(node_factory, "session_factory", SimpleNamespace(create_session=FakeSession)) - monkeypatch.setattr(node_factory, "select", MagicMock(return_value=FakeSelect())) monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) result = node_factory.fetch_memory( @@ -198,35 +189,22 @@ class TestFetchMemory: ) assert result is memory - token_buffer_memory.assert_called_once_with( - conversation=conversation, - model_instance=sentinel.model_instance, - ) - - def test_uses_configured_session_factory_without_flask_app_context(self, monkeypatch: pytest.MonkeyPatch): - class FakeSelect: - def where(self, *_args): - return self - - class FakeSession: - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def scalar(self, _stmt): - return sentinel.conversation + loaded_conversation = token_buffer_memory.call_args.kwargs["conversation"] + assert isinstance(loaded_conversation, Conversation) + assert loaded_conversation.id == "conversation-id" + assert token_buffer_memory.call_args.kwargs["model_instance"] is sentinel.model_instance + def test_uses_configured_session_factory_without_flask_app_context( + self, monkeypatch: pytest.MonkeyPatch, memory_session_maker: sessionmaker[Session] + ): class RaisingDB: @property def engine(self): raise RuntimeError("Working outside of application context.") token_buffer_memory = MagicMock(return_value=sentinel.memory) + _persist_conversation(memory_session_maker) monkeypatch.setattr(node_factory, "db", RaisingDB(), raising=False) - monkeypatch.setattr(node_factory, "session_factory", SimpleNamespace(create_session=FakeSession)) - monkeypatch.setattr(node_factory, "select", MagicMock(return_value=FakeSelect())) monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) result = node_factory.fetch_memory( diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py index 00aecefbaa6..f34bb9ed3ec 100644 --- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py @@ -1,6 +1,6 @@ import json from types import SimpleNamespace -from unittest.mock import Mock, call +from unittest.mock import Mock import pytest from pydantic import ValidationError @@ -19,7 +19,6 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig -from models.model import App, IconType from services.agent.dsl_entities import ( AGENT_NODE_JOB_DSL_KEY, AGENT_PACKAGE_REF_KEY, @@ -153,6 +152,43 @@ def test_agent_package_round_trips_as_strict_dsl_dto() -> None: assert restored == package +def test_agent_package_normalizes_legacy_null_missing_asset_file_ids() -> None: + package = make_portable_agent_package( + _agent(), + AgentSoulConfig.model_validate( + { + "config_skills": [{"name": "research", "file_id": "skill-file"}], + "config_files": [{"name": "guide.md", "file_kind": "tool_file", "file_id": "config-file"}], + } + ), + ).model_dump(mode="json") + package["soul"]["config_skills"][0]["file_id"] = None + package["soul"]["config_files"][0]["file_id"] = None + + restored = AgentPackage.model_validate(package) + + assert restored.soul.config_skills[0].file_id == "" + assert restored.soul.config_files[0].file_id == "" + assert restored.model_dump(mode="json")["soul"]["config_skills"][0]["file_id"] == "" + assert restored.model_dump(mode="json")["soul"]["config_files"][0]["file_id"] == "" + + +@pytest.mark.parametrize( + "asset", + [ + {"name": "research", "file_id": None, "is_missing": False}, + {"name": "guide.md", "file_kind": "tool_file", "file_id": None, "is_missing": False}, + ], +) +def test_agent_package_rejects_null_file_id_for_available_assets(asset: dict) -> None: + package = make_portable_agent_package(_agent(), AgentSoulConfig()).model_dump(mode="json") + target = "config_files" if "file_kind" in asset else "config_skills" + package["soul"][target] = [asset] + + with pytest.raises(ValidationError): + AgentPackage.model_validate(package) + + def test_import_warnings_cover_runtime_setup_removed_from_package(monkeypatch) -> None: soul = AgentSoulConfig.model_validate( { @@ -324,7 +360,7 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(monkeypat assert session.flush.call_count == 2 -def test_import_workflow_packages_replaces_bindings_and_reuses_roster_package() -> None: +def test_import_workflow_packages_materializes_every_package_binding_as_inline() -> None: package = make_portable_agent_package(_agent(), AgentSoulConfig()) graph = { "nodes": [ @@ -351,18 +387,15 @@ def test_import_workflow_packages_replaces_bindings_and_reuses_roster_package() session = Mock() session.scalars.return_value.all.return_value = [old_binding] service = AgentDslService(session) - roster_result = SimpleNamespace( - agent=SimpleNamespace(id="roster-agent"), - snapshot=SimpleNamespace(id="roster-snapshot"), - warnings=[DslImportWarning(code="roster", path="agent", message="roster warning")], - ) - inline_result = SimpleNamespace( - agent=SimpleNamespace(id="inline-agent"), - snapshot=SimpleNamespace(id="inline-snapshot"), - warnings=[DslImportWarning(code="inline", path="agent", message="inline warning")], - ) - service._create_imported_roster_agent_app = Mock(return_value=roster_result) - service._create_imported_inline_agent = Mock(return_value=inline_result) + imported_results = [ + SimpleNamespace( + agent=SimpleNamespace(id=f"inline-agent-{index}"), + snapshot=SimpleNamespace(id=f"inline-snapshot-{index}"), + warnings=[DslImportWarning(code=f"inline-{index}", path="agent", message="inline warning")], + ) + for index in range(1, 4) + ] + service._create_imported_inline_agent = Mock(side_effect=imported_results) workflow = SimpleNamespace( tenant_id="tenant-1", app_id="app-1", @@ -379,15 +412,25 @@ def test_import_workflow_packages_replaces_bindings_and_reuses_roster_package() ) session.delete.assert_called_once_with(old_binding) - service._create_imported_roster_agent_app.assert_called_once() - service._create_imported_inline_agent.assert_called_once() - assert [warning.code for warning in warnings] == ["roster", "roster", "inline"] - assert result["nodes"][0]["data"]["agent_binding"]["agent_id"] == "roster-agent" - assert result["nodes"][2]["data"]["agent_binding"]["agent_id"] == "inline-agent" + assert service._create_imported_inline_agent.call_count == 3 + assert [call.kwargs["node_id"] for call in service._create_imported_inline_agent.call_args_list] == [ + "roster-1", + "roster-2", + "inline", + ] + assert [warning.code for warning in warnings] == ["inline-1", "inline-2", "inline-3"] + bindings = [result["nodes"][index]["data"]["agent_binding"] for index in range(3)] + assert [binding["agent_id"] for binding in bindings] == [ + "inline-agent-1", + "inline-agent-2", + "inline-agent-3", + ] + assert all(binding["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value for binding in bindings) assert AGENT_NODE_JOB_DSL_KEY not in result["nodes"][0]["data"] assert json.loads(workflow.graph) == result added_bindings = [item.args[0] for item in session.add.call_args_list] assert all(isinstance(binding, WorkflowAgentNodeBinding) for binding in added_bindings) + assert all(binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT for binding in added_bindings) @pytest.mark.parametrize( @@ -509,35 +552,6 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypat ] -def test_create_imported_roster_agent_app_prefixes_warnings(monkeypatch) -> None: - session = Mock() - service = AgentDslService(session) - service._configure_visible_agent_app_after_commit = Mock() - result = SimpleNamespace( - agent=_agent(), - snapshot=_snapshot(), - warnings=[DslImportWarning(code="setup", path="soul.model", message="setup")], - ) - service.import_agent_app_package = Mock(return_value=result) - send = Mock() - monkeypatch.setattr("services.agent.dsl_service.app_was_created.send", send) - - imported = service._create_imported_roster_agent_app( - tenant_id="tenant-1", - account=SimpleNamespace(id="account-1"), - package=make_portable_agent_package(_agent(), AgentSoulConfig()), - package_path="agent_packages.agent_1", - ) - - app = session.add.call_args.args[0] - assert isinstance(app, App) - assert app.name == "Portable Agent" - assert app.enable_site is True - assert app.enable_api is True - send.assert_called_once_with(app, account=SimpleNamespace(id="account-1"), session=session) - assert imported.warnings[0].path == "agent_packages.agent_1.soul.model" - - def test_create_imported_inline_agent_uses_import_provenance() -> None: service = AgentDslService(Mock()) soul = AgentSoulConfig(config_note="inline") @@ -687,51 +701,6 @@ def test_unique_roster_name_uses_first_available_suffix() -> None: assert result == "Agent import 2" -def test_configure_visible_agent_app_runs_after_commit(monkeypatch) -> None: - session = Mock() - listener = Mock() - monkeypatch.setattr("services.agent.dsl_service.event.listen", listener) - service = AgentDslService(session) - - service._configure_visible_agent_app_after_commit( - tenant_id="tenant-1", - app_id="app-1", - account_id="account-1", - ) - - listener.assert_called_once_with(session, "after_commit", listener.call_args.args[2], once=True) - configure = listener.call_args.args[2] - from services.enterprise import rbac_service - from services.enterprise.enterprise_service import EnterpriseService - from services.feature_service import FeatureService - - sync = Mock() - update_access = Mock() - monkeypatch.setattr(rbac_service, "try_sync_creator_access_policy_member_bindings", sync) - monkeypatch.setattr(EnterpriseService.WebAppAuth, "update_app_access_mode", update_access) - monkeypatch.setattr( - FeatureService, - "get_system_features", - Mock(return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))), - ) - configure(session) - update_access.assert_not_called() - - FeatureService.get_system_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)) - configure(session) - update_access.assert_called_once_with("app-1", "private") - assert sync.call_args_list == [ - call("tenant-1", "account-1", rbac_service.RBACResourceType.APP, "app-1"), - call("tenant-1", "account-1", rbac_service.RBACResourceType.APP, "app-1"), - ] - - monkeypatch.setattr(rbac_service, "try_sync_creator_access_policy_member_bindings", Mock(side_effect=RuntimeError)) - logger = Mock() - monkeypatch.setattr("services.agent.dsl_service.logger", logger) - configure(session) - logger.exception.assert_called_once() - - def test_require_helpers_and_graph_detection() -> None: session = Mock() service = AgentDslService(session) @@ -750,7 +719,5 @@ def test_require_helpers_and_graph_detection() -> None: assert AgentDslService._agent_icon_type(AgentIconType.EMOJI.value) == AgentIconType.EMOJI assert AgentDslService._agent_icon_type(None) is None - assert AgentDslService._app_icon_type(IconType.IMAGE.value) == IconType.IMAGE - assert AgentDslService._app_icon_type(None) == IconType.EMOJI assert is_agent_v2_graph({"nodes": [_agent_node("agent")]}) is True assert is_agent_v2_graph({"nodes": ["invalid", {"data": {"type": "start"}}]}) is False diff --git a/dify-agent-runtime/Makefile b/dify-agent-runtime/Makefile index 40cf168c18a..fffa071cc3b 100644 --- a/dify-agent-runtime/Makefile +++ b/dify-agent-runtime/Makefile @@ -94,7 +94,7 @@ integration-up: docker run -d --name $(CONTAINER_NAME_NOISO) \ -p $(HOST_PORT_NOISO):5004 \ -e SHELLCTL_AUTH_TOKEN=$(AUTH_TOKEN_NOISO) \ - -e ENABLE_PATH_ISOLATION=false \ + -e SHELLCTL_ENABLE_PATH_ISOLATION=false \ $(IMAGE_NAME) @echo 'CONTAINER_NAME_NOISO=$(CONTAINER_NAME_NOISO)' >> $(STATE_FILE) @echo 'HOST_PORT_NOISO=$(HOST_PORT_NOISO)' >> $(STATE_FILE) diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md index 7abc105211f..49ecc5890bf 100644 --- a/dify-agent-runtime/README.md +++ b/dify-agent-runtime/README.md @@ -78,12 +78,12 @@ The runner automatically creates `$CWD/.tmp` and sets `TMPDIR`, `TMP`, `TEMP` to ### Environment Variables -| Variable | Default | Description | -| ----------------------- | --------------- | ------------------------------------------------ | -| `ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely | -| `LANDLOCK_RW_PATHS` | _(empty)_ | Comma-separated RW directories (besides `$HOME`) | -| `LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories | -| `LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access | +| Variable | Default | Description | +| -------------------------------- | --------------- | ------------------------------------------------ | +| `SHELLCTL_ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely | +| `SHELLCTL_LANDLOCK_RW_PATHS` | _(empty)_ | Comma-separated RW directories (besides `$HOME`) | +| `SHELLCTL_LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories | +| `SHELLCTL_LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access | Requires Linux ≥ 5.13. On unsupported kernels, a warning is printed to stderr. diff --git a/dify-agent-runtime/internal/envvar/envvar.go b/dify-agent-runtime/internal/envvar/envvar.go index 7ecbd96b503..e93af4e828c 100644 --- a/dify-agent-runtime/internal/envvar/envvar.go +++ b/dify-agent-runtime/internal/envvar/envvar.go @@ -8,16 +8,16 @@ import "os" const ( // EnvEnablePathIsolation controls whether Landlock is applied at all. - EnvEnablePathIsolation = "ENABLE_PATH_ISOLATION" + EnvEnablePathIsolation = "SHELLCTL_ENABLE_PATH_ISOLATION" // EnvRWPaths overrides the default RW directories (comma-separated). - EnvRWPaths = "LANDLOCK_RW_PATHS" + EnvRWPaths = "SHELLCTL_LANDLOCK_RW_PATHS" // EnvROPaths overrides the default RO+exec directories (comma-separated). - EnvROPaths = "LANDLOCK_RO_PATHS" + EnvROPaths = "SHELLCTL_LANDLOCK_RO_PATHS" // EnvRWDevPaths overrides the default device files (comma-separated). - EnvRWDevPaths = "LANDLOCK_RW_DEV_PATHS" + EnvRWDevPaths = "SHELLCTL_LANDLOCK_RW_DEV_PATHS" ) // --- Agent Stub --- diff --git a/dify-agent-runtime/internal/landlock/config.go b/dify-agent-runtime/internal/landlock/config.go index 73cd791caa5..46e10e55474 100644 --- a/dify-agent-runtime/internal/landlock/config.go +++ b/dify-agent-runtime/internal/landlock/config.go @@ -63,10 +63,6 @@ func DefaultConfig(home, cwd, jobDir string) *Config { // // If a variable is set (even to empty string), its value replaces the default. // Set to empty to grant no additional paths beyond $HOME. -// -// LANDLOCK_RW_PATHS — comma-separated RW dirs (default: empty) -// LANDLOCK_RO_PATHS — comma-separated RO dirs (default: system paths) -// LANDLOCK_RW_DEV_PATHS — comma-separated dev files (default: /dev/null,...) func ConfigFromEnv(home, cwd, jobDir string) *Config { cfg := DefaultConfig(home, cwd, jobDir) diff --git a/dify-agent-runtime/tests/acceptance_test.go b/dify-agent-runtime/tests/acceptance_test.go index caf3b773791..aed05e6ac9d 100644 --- a/dify-agent-runtime/tests/acceptance_test.go +++ b/dify-agent-runtime/tests/acceptance_test.go @@ -622,7 +622,7 @@ func TestLandlockCannotReadOtherAgentHome(t *testing.T) { // --- Landlock Disable / Bypass Tests --- // TestLandlockDisabledAllowsWriteOutsideHome uses the pre-started no-isolation -// container (ENABLE_PATH_ISOLATION=false) and verifies that isolation is off. +// container (SHELLCTL_ENABLE_PATH_ISOLATION=false) and verifies that isolation is off. func TestLandlockDisabledAllowsWriteOutsideHome(t *testing.T) { tgt, ok := noIsolationTarget() if !ok { @@ -645,7 +645,7 @@ func TestLandlockDisabledAllowsWriteOutsideHome(t *testing.T) { } // TestLandlockEnvBypassBlocked verifies that a caller cannot set -// ENABLE_PATH_ISOLATION=false in job env to escape the sandbox. +// SHELLCTL_ENABLE_PATH_ISOLATION=false in job env to escape the sandbox. func TestLandlockEnvBypassBlocked(t *testing.T) { for _, tgt := range targets() { t.Run(tgt.name, func(t *testing.T) { @@ -653,8 +653,8 @@ func TestLandlockEnvBypassBlocked(t *testing.T) { result := runJob(t, tgt, map[string]any{ "script": "touch /opt/landlock-bypass-test 2>&1; echo exit=$?", "env": map[string]string{ - "HOME": "/home/dify", - "ENABLE_PATH_ISOLATION": "false", + "HOME": "/home/dify", + "SHELLCTL_ENABLE_PATH_ISOLATION": "false", }, "timeout": 10, }) @@ -662,7 +662,7 @@ func TestLandlockEnvBypassBlocked(t *testing.T) { output := result["output"].(string) // The write should still be denied despite the env override attempt. if strings.Contains(output, "exit=0") { - t.Errorf("expected write to /opt to be DENIED even with ENABLE_PATH_ISOLATION=false in job env, but it succeeded: %q", output) + t.Errorf("expected write to /opt to be DENIED even with SHELLCTL_ENABLE_PATH_ISOLATION=false in job env, but it succeeded: %q", output) } }) } diff --git a/docker/.env.example b/docker/.env.example index dfddb102949..d46247f696f 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -270,6 +270,15 @@ DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub # Replace this development default in production. # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY +# Agent sandbox path isolation (Landlock), applied inside the local_sandbox container. +# Set SHELLCTL_ENABLE_PATH_ISOLATION=false to disable Landlock entirely (default: true). +SHELLCTL_ENABLE_PATH_ISOLATION=true +# The paths below override the built-in defaults when set (even to an empty value). +# Leave them commented out to keep the defaults; setting SHELLCTL_LANDLOCK_RO_PATHS or +# SHELLCTL_LANDLOCK_RW_DEV_PATHS to an empty string removes all default paths and can break exec. +# SHELLCTL_LANDLOCK_RW_PATHS= +# SHELLCTL_LANDLOCK_RO_PATHS= +# SHELLCTL_LANDLOCK_RW_DEV_PATHS= # Nginx and Docker Compose NGINX_SERVER_NAME=_ diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index e320f9c1c98..8404764f58d 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -527,7 +527,14 @@ services: image: langgenius/dify-agent-local-sandbox:1.16.0-rc1 restart: always environment: - SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + - SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + # Landlock path isolation. SHELLCTL_ENABLE_PATH_ISOLATION defaults to true in the runtime. + - SHELLCTL_ENABLE_PATH_ISOLATION=${SHELLCTL_ENABLE_PATH_ISOLATION:-true} + # Passed through only when set (uncommented) in .env; leaving them unset keeps + # the runtime's built-in defaults. Setting them to empty removes all default paths. + - SHELLCTL_LANDLOCK_RW_PATHS + - SHELLCTL_LANDLOCK_RO_PATHS + - SHELLCTL_LANDLOCK_RW_DEV_PATHS healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] interval: 30s diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0bbd77a9e94..69ea2c81103 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -533,7 +533,14 @@ services: image: langgenius/dify-agent-local-sandbox:1.16.0-rc1 restart: always environment: - SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + - SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + # Landlock path isolation. SHELLCTL_ENABLE_PATH_ISOLATION defaults to true in the runtime. + - SHELLCTL_ENABLE_PATH_ISOLATION=${SHELLCTL_ENABLE_PATH_ISOLATION:-true} + # Passed through only when set (uncommented) in .env; leaving them unset keeps + # the runtime's built-in defaults. Setting them to empty removes all default paths. + - SHELLCTL_LANDLOCK_RW_PATHS + - SHELLCTL_LANDLOCK_RO_PATHS + - SHELLCTL_LANDLOCK_RW_DEV_PATHS healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] interval: 30s diff --git a/docker/envs/core-services/dify-agent.env.example b/docker/envs/core-services/dify-agent.env.example index be757e456bb..7eeee49fdbe 100644 --- a/docker/envs/core-services/dify-agent.env.example +++ b/docker/envs/core-services/dify-agent.env.example @@ -26,3 +26,17 @@ DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub # Replace this development default in production. # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY + +# --- Agent sandbox path isolation (Landlock) --- +# Applied by the runtime inside the local_sandbox container. +# Set SHELLCTL_ENABLE_PATH_ISOLATION=false to disable Landlock entirely (default: true). +SHELLCTL_ENABLE_PATH_ISOLATION=true +# The paths below override the built-in defaults when set (even to an empty value). +# Leave them commented out to keep the defaults; setting SHELLCTL_LANDLOCK_RO_PATHS or +# SHELLCTL_LANDLOCK_RW_DEV_PATHS to an empty string removes all default paths and can break exec. +# Comma-separated additional read-write directories (default: none beyond $HOME). +# SHELLCTL_LANDLOCK_RW_PATHS= +# Comma-separated read-only + execute directories (default: /usr,/bin,/sbin,/lib,/lib64,/etc,/proc,/opt/dify-agent-tools,/opt/homebrew,/snap). +# SHELLCTL_LANDLOCK_RO_PATHS= +# Comma-separated read-write device files (default: /dev/null,/dev/zero,/dev/urandom,/dev/random,/dev/tty). +# SHELLCTL_LANDLOCK_RW_DEV_PATHS= diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 10eacdbe44e..a5eed78c83a 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -64,6 +64,9 @@ pnpm -C e2e e2e:headed -- --tags @smoke # slow down browser actions for local debugging E2E_SLOW_MO=500 pnpm -C e2e e2e:headed -- --tags @smoke + +# focused keyboard and cross-browser smoke coverage +E2E_BROWSER=webkit pnpm -C e2e e2e -- --tags @browser-smoke ``` Frontend artifact behavior: @@ -128,7 +131,11 @@ This removes: - `e2e/.auth` - `e2e/.logs` - `e2e/.logs-non-external` +- `e2e/.logs-webkit` - `e2e/cucumber-report` +- `e2e/cucumber-report-non-external` +- `e2e/cucumber-report-webkit` +- `e2e/seed-report` Start the full middleware stack: @@ -170,9 +177,20 @@ Artifacts and diagnostics: - `cucumber-report/report.html`: HTML report - `cucumber-report/report.json`: JSON report - `cucumber-report/artifacts/`: failure screenshots and HTML captures +- `cucumber-report-non-external/`: Chromium core report preserved before later CI lanes +- `cucumber-report-webkit/`: focused WebKit keyboard/browser smoke report - `.logs/cucumber-api.log`: backend startup log - `.logs/cucumber-web.log`: frontend startup log - `.logs-non-external/`: non-external logs preserved before an external CI run +- `.logs-webkit/`: focused WebKit lane logs +- `seed-report/`: JSON readiness reports emitted by external runtime seed packs + +CI enables a JSON report gate after Cucumber exits. The gate asserts minimum selected and passed +scenario counts, maximum skipped counts, and zero unexplained skips. A skipped scenario counts as +an explained blocked precondition only when its skipped step attaches a `Blocked precondition:` +reason. This keeps feature-gated and preflight readiness reporting visible without allowing a +zero-coverage or silently skipped CI run to pass. Set `E2E_CUCUMBER_REPORT_PROFILE` to select the +checked-in `core`, `webkit-browser-smoke`, or `external` thresholds and allowed blocked tags. Open the HTML report locally with: @@ -212,6 +230,7 @@ Feature: Create dataset - `@external-model` — scenario execution can call a real model provider. Use this only for runtime requests, not for scenarios that only require an active model fixture. - `@external-tool` — scenario execution can call a real third-party tool provider. Use this only for runtime tool execution, not for plugin installation, discovery, or local deterministic tools. - `@microphone` — runs the scenario in an isolated Chromium instance backed by the checked-in fake audio fixture and grants microphone permission only to that scenario context. +- `@browser-smoke` — focused keyboard and navigation coverage that runs in Chromium with the core suite and again in WebKit on CI. - `@skip` — excluded from all runs External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` reads `E2E_EXTERNAL_RUNTIME_SEED_SPECS`, defaulting to `agent-v2:external-runtime`, and runs the matching seed packs before the external suite. `pnpm -C e2e e2e:external` reads `E2E_EXTERNAL_RUNTIME_TAGS`, defaulting to `(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview`. diff --git a/e2e/features/accessibility/keyboard-navigation.feature b/e2e/features/accessibility/keyboard-navigation.feature new file mode 100644 index 00000000000..229552affa8 --- /dev/null +++ b/e2e/features/accessibility/keyboard-navigation.feature @@ -0,0 +1,30 @@ +@accessibility @keyboard @browser-smoke +Feature: Keyboard navigation + + @authenticated + Scenario: Skip repeated navigation and move focus to the main content + Given I am signed in as the default E2E admin + When I open the default console entry + And I focus and activate the skip navigation link with the keyboard + Then the console main content should have keyboard focus + + @unauthenticated + Scenario: Sign in by following the form tab order + Given I am not signed in + When I open the sign-in page + And I complete the sign-in form using only the keyboard + Then I should be on the console home + + @authenticated + Scenario: Closing the account menu restores focus to its trigger + Given I am signed in as the default E2E admin + When I open the apps console + And I open and close the account menu using the keyboard + Then the account menu trigger should regain keyboard focus + + @authenticated + Scenario: Closing the create app menu restores focus to its trigger + Given I am signed in as the default E2E admin + When I open the apps console + And I open and close the create app menu using the keyboard + Then the create app menu trigger should regain keyboard focus diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 26ab4bd1ae8..b02d662c5a1 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -26,9 +26,8 @@ Use tags in three layers: - `@core` — stable non-runtime scenario expected to run in the regular Agent v2 suite when its explicit preconditions are met. Do not apply `@core` to Preview/Test Run, Web app chat runtime, or Backend service API chat runtime scenarios. - `@infra` — infrastructure or readiness checks. - `@build` — Build mode and Build draft behavior. -- `@build-unavailable-resources` — feature-gated Build chat recovery when the user requests unavailable Skills or Tools. - `@files` — Files section upload, display, and fixture behavior. -- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format and size rejection remain feature-gated until their product contracts are stable. +- `@files-limits` — stable file limit behavior, currently covering multiple-file drop rejection. - `@knowledge` — Knowledge Retrieval configuration display, persistence, and reference cleanup. - `@advanced-settings` — Env Editor, Content Moderation, and related Advanced Settings behavior. - `@agent-create` — Agent Roster creation and initial Configure navigation. @@ -232,6 +231,6 @@ Order blocked steps by the real owner of the first unresolved condition. If a sc Use partial coverage only when current product behavior is intentionally narrower than the written requirement and the test still asserts a real user-visible behavior. Example: Files are currently flat in Agent config files, so the flat Files list can be asserted while tree display remains blocked until product support exists. -Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format and size rejection remain feature-gated until the product exposes stable Agent config file restrictions and user-visible error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration. +Multiple-file drop is covered as stable `@core @files-limits` behavior. Add file format or size rejection coverage only after the product exposes stable Agent config file restrictions and user-visible error states; do not encode undefined behavior as permanently skipped scenarios. Do not mark a scenario as complete if it only proves setup state and does not assert the user-visible behavior or persisted product contract required by the case. diff --git a/e2e/features/agent-v2/access-point.feature b/e2e/features/agent-v2/access-point.feature index 431133e50ac..4bc480b00d3 100644 --- a/e2e/features/agent-v2/access-point.feature +++ b/e2e/features/agent-v2/access-point.feature @@ -16,7 +16,6 @@ Feature: Agent v2 Access Point When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section Then I should see the Agent v2 Web app access URL - And I record the current Agent v2 orchestration draft When I copy the Agent v2 Web app access URL Then the Agent v2 Web app access URL should show it was copied And the current Agent v2 orchestration draft should be unchanged @@ -30,7 +29,6 @@ Feature: Agent v2 Access Point When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section Then I should see the Agent v2 Web app access URL - And I record the current Agent v2 orchestration draft When I launch the Agent v2 Web app Then the Agent v2 Web app should open in a new tab And the current Agent v2 orchestration draft should be unchanged @@ -42,7 +40,6 @@ Feature: Agent v2 Access Point And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section - And I record the current Agent v2 orchestration draft And I open Agent v2 Embedded configuration Then I should see the Agent v2 Embedded configuration dialog And the current Agent v2 orchestration draft should be unchanged @@ -54,7 +51,6 @@ Feature: Agent v2 Access Point And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section - And I record the current Agent v2 orchestration draft And I open Agent v2 Web app customization Then I should see the Agent v2 Web app customization dialog And the current Agent v2 orchestration draft should be unchanged @@ -66,7 +62,6 @@ Feature: Agent v2 Access Point And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section - And I record the current Agent v2 orchestration draft And I open Agent v2 Web app settings Then I should see the Agent v2 Web app settings dialog And the current Agent v2 orchestration draft should be unchanged @@ -86,20 +81,6 @@ Feature: Agent v2 Access Point When I refresh the current page Then Agent v2 Web app access should be in service - @web-app-access @published-web-app @feature-gated - Scenario: Disabled Web app public URL shows an unavailable state - Given I am signed in as the default E2E admin - And Agent v2 disabled Web app public unavailable state is available - And a basic configured Agent v2 test agent has been created via API - And the Agent v2 draft has been published via API - And Agent v2 Web app access has been enabled via API - When I open the Agent v2 configure page from the Agent Roster - And I switch to the Agent v2 Access Point section - And I disable Agent v2 Web app access - Then Agent v2 Web app access should be out of service - When I open the disabled Agent v2 Web app URL - Then the disabled Agent v2 Web app should show an unavailable state - @core @workflow-reference Scenario: Workflow access shows the referencing workflow Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/agent-edit.feature b/e2e/features/agent-v2/agent-edit.feature index f9fadd314dd..aa6e7800e8f 100644 --- a/e2e/features/agent-v2/agent-edit.feature +++ b/e2e/features/agent-v2/agent-edit.feature @@ -31,15 +31,6 @@ Feature: Agent v2 Agent Edit page When I open the preseeded Agent v2 configure page for "E2E New Agent Builder Tool States" from the Agent Roster Then I should see the Agent v2 tool state fixture tools - @tool-error-state @tool-states-agent @feature-gated - Scenario: Tool credential error states are visible on the Agent Edit page - Given I am signed in as the default E2E admin - And Agent v2 Tool credential error state is available - And the Agent Builder preseeded Agent "E2E New Agent Builder Tool States" is available - And the Agent Builder preseeded Agent "E2E New Agent Builder Tool States" includes the tool state fixture configuration - When I open the preseeded Agent v2 configure page for "E2E New Agent Builder Tool States" from the Agent Roster - Then Agent v2 Tool credential error state should be available - @core @dual-retrieval-fixture Scenario: Dual Knowledge Retrieval settings are visible on the Agent Edit page Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/build-draft.feature b/e2e/features/agent-v2/build-draft.feature index 0968a32ccc8..12b045486a3 100644 --- a/e2e/features/agent-v2/build-draft.feature +++ b/e2e/features/agent-v2/build-draft.feature @@ -50,23 +50,15 @@ Feature: Agent v2 build draft When I open the Agent v2 configure page Then I should see the Agent v2 Build draft pending changes And I should see the small Agent v2 file in the Files section - And I should see the e2e-summary-skill Skill in the Skills section - And I should see the supported E2E environment variable in Advanced Settings And the normal Agent v2 draft should still use the normal E2E prompt When I discard the Agent v2 Build draft - Then I should see the normal E2E prompt in the Agent v2 prompt editor - And I should not see the small Agent v2 file in the Files section - And I should not see the e2e-summary-skill Skill in the Skills section - And I should not see the supported E2E environment variable in Advanced Settings - And the Agent v2 draft should not include the supported Build draft config + Then the Agent v2 draft should not include the supported Build draft config And the Agent v2 Build draft should no longer be active When I refresh the current page Then I should see the normal E2E prompt in the Agent v2 prompt editor And I should not see the small Agent v2 file in the Files section And I should not see the e2e-summary-skill Skill in the Skills section And I should not see the supported E2E environment variable in Advanced Settings - And the Agent v2 draft should not include the supported Build draft config - And the Agent v2 Build draft should no longer be active @external-model @agent-backend-runtime @stable-model Scenario: Applying a pending Build draft updates the normal Agent configuration @@ -95,9 +87,6 @@ Feature: Agent v2 build draft When I open the Agent v2 configure page Then I should see the Agent v2 Build draft pending changes And I should see the updated E2E prompt in the Agent v2 prompt editor - And I should see the small Agent v2 file in the Files section - And I should see the e2e-summary-skill Skill in the Skills section - And I should see the supported E2E environment variable in Advanced Settings And the normal Agent v2 draft should still use the normal E2E prompt When I apply the Agent v2 Build draft via API Then the Agent v2 draft should include the supported Build draft config @@ -138,20 +127,3 @@ Feature: Agent v2 build draft Then I should see the Agent v2 Build draft pending changes And I should see the updated E2E prompt in the Agent v2 prompt editor And the normal Agent v2 draft should still use the normal E2E prompt - - @build-tool-writeback @feature-gated - Scenario: Applying a Build draft can add Dify Tools to the Agent configuration - Given I am signed in as the default E2E admin - And Agent v2 Build chat Dify Tool writeback is available - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 Build chat Dify Tool writeback should be available - - @build-unavailable-resources @feature-gated @stable-model - Scenario: Build chat reports unavailable Skill or Tool requests clearly - Given I am signed in as the default E2E admin - And Agent v2 Build chat unavailable Skill and Tool recovery is available - And the Agent Builder stable chat model is available - And a runnable Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 Build chat unavailable Skill and Tool recovery should be available diff --git a/e2e/features/agent-v2/files.feature b/e2e/features/agent-v2/files.feature index 233e57dc69e..cc770c66674 100644 --- a/e2e/features/agent-v2/files.feature +++ b/e2e/features/agent-v2/files.feature @@ -36,22 +36,6 @@ Feature: Agent v2 files When I refresh the current page Then I should see the special-name Agent v2 file in the Files section - @files-limits @feature-gated - Scenario: Unsupported Agent v2 file formats show a clear rejection reason - Given I am signed in as the default E2E admin - And Agent v2 unsupported file format rejection is available - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 unsupported file format rejection should be available - - @files-limits @feature-gated - Scenario: Oversized Agent v2 files show a clear rejection reason - Given I am signed in as the default E2E admin - And Agent v2 oversized file rejection is available - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 oversized file rejection should be available - @core @files-limits Scenario: Dropping multiple Agent v2 files at once is rejected Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/output-variables.feature b/e2e/features/agent-v2/output-variables.feature index 9410ff656ff..2ca7ce1e761 100644 --- a/e2e/features/agent-v2/output-variables.feature +++ b/e2e/features/agent-v2/output-variables.feature @@ -44,14 +44,3 @@ Feature: Agent v2 output variables When I refresh the current page And I open the Agent v2 workflow node panel Then the Agent v2 workflow node task should reference the renamed file output - - @output-reference-delete @feature-gated @stable-model - Scenario: Workflow Agent v2 prompt output reference deletion remains explicit - Given I am signed in as the default E2E admin - And Agent v2 workflow task output reference deletion consistency is available - And the Agent Builder stable chat model is available - And a workflow app with an Agent v2 node has been created via API - When I open the app from the app list - And I open the Agent v2 workflow node panel - And I insert a file output reference from the Agent v2 workflow node task editor - Then Agent v2 workflow task output reference deletion consistency should be available diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature index 1c707306ef4..265599ecd16 100644 --- a/e2e/features/apps/share-app.feature +++ b/e2e/features/apps/share-app.feature @@ -1,6 +1,7 @@ -@apps @authenticated @core +@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 diff --git a/e2e/features/apps/workflow-run-publish.feature b/e2e/features/apps/workflow-run.feature similarity index 61% rename from e2e/features/apps/workflow-run-publish.feature rename to e2e/features/apps/workflow-run.feature index 8640a7490b5..aedcdc3360c 100644 --- a/e2e/features/apps/workflow-run-publish.feature +++ b/e2e/features/apps/workflow-run.feature @@ -1,13 +1,10 @@ @apps @authenticated @core @mode-matrix -Feature: Workflow run and publish +Feature: Workflow run - Scenario: Run and publish a minimal workflow app + Scenario: Run a minimal 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 run the workflow Then the workflow run should succeed - When I open the publish panel - And I publish the app - Then the app should be marked as published diff --git a/e2e/features/auth/sign-out.feature b/e2e/features/auth/sign-out.feature index 4446beaf766..e6b276faa64 100644 --- a/e2e/features/auth/sign-out.feature +++ b/e2e/features/auth/sign-out.feature @@ -1,12 +1,5 @@ @auth @authenticated @core Feature: Sign out - Scenario: Sign out from the apps console - Given I am signed in as the default E2E admin - When I open the apps console - And I open the account menu - And I sign out - Then I should be on the sign-in page - Scenario: Redirect back to sign-in when reopening the apps console after signing out Given I am signed in as the default E2E admin When I open the apps console diff --git a/e2e/features/step-definitions/accessibility/keyboard-navigation.steps.ts b/e2e/features/step-definitions/accessibility/keyboard-navigation.steps.ts new file mode 100644 index 00000000000..824c98ac130 --- /dev/null +++ b/e2e/features/step-definitions/accessibility/keyboard-navigation.steps.ts @@ -0,0 +1,83 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { adminCredentials } from '../../../fixtures/auth' +import { e2eBrowser } from '../../../test-env' + +const getAccountMenuTrigger = (world: DifyWorld) => + world.getPage().getByRole('button', { name: 'Account' }) + +const getCreateAppMenuTrigger = (world: DifyWorld) => + world.getPage().getByRole('main').getByRole('button', { name: 'Create', exact: true }) + +When( + 'I focus and activate the skip navigation link with the keyboard', + async function (this: DifyWorld) { + const page = this.getPage() + const skipLink = page.getByRole('link', { name: 'Skip to main content' }) + const nextClickableItemKey = e2eBrowser === 'webkit' ? 'Alt+Tab' : 'Tab' + + await page.keyboard.press(nextClickableItemKey) + await expect(skipLink).toBeFocused() + await page.keyboard.press('Enter') + }, +) + +Then('the console main content should have keyboard focus', async function (this: DifyWorld) { + await expect(this.getPage().getByRole('main')).toBeFocused() +}) + +When('I complete the sign-in form using only the keyboard', async function (this: DifyWorld) { + const page = this.getPage() + const email = page.getByLabel('Email address') + const password = page.getByLabel('Password', { exact: true }) + const showPassword = page.getByRole('button', { name: 'Show password' }) + const submit = page.getByRole('button', { name: 'Sign in' }) + + await expect(email).toBeVisible() + for (let tabPresses = 0; tabPresses < 10; tabPresses += 1) { + await page.keyboard.press('Tab') + if (await email.evaluate((element) => element === document.activeElement)) break + } + await expect(email).toBeFocused() + await page.keyboard.insertText(adminCredentials.email) + + await page.keyboard.press('Tab') + await expect(password).toBeFocused() + await page.keyboard.insertText(adminCredentials.password) + + await page.keyboard.press('Tab') + await expect(showPassword).toBeFocused() + await page.keyboard.press('Tab') + await expect(submit).toBeFocused() + await page.keyboard.press('Enter') +}) + +When('I open and close the account menu using the keyboard', async function (this: DifyWorld) { + const page = this.getPage() + const trigger = getAccountMenuTrigger(this) + + await expect(trigger).toBeEnabled() + await trigger.press('Enter') + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + await page.keyboard.press('Escape') + await expect(trigger).toHaveAttribute('aria-expanded', 'false') +}) + +Then('the account menu trigger should regain keyboard focus', async function (this: DifyWorld) { + await expect(getAccountMenuTrigger(this)).toBeFocused() +}) + +When('I open and close the create app menu using the keyboard', async function (this: DifyWorld) { + const page = this.getPage() + const trigger = getCreateAppMenuTrigger(this) + + await trigger.press('Enter') + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + await page.keyboard.press('Escape') + await expect(trigger).toHaveAttribute('aria-expanded', 'false') +}) + +Then('the create app menu trigger should regain keyboard focus', async function (this: DifyWorld) { + await expect(getCreateAppMenuTrigger(this)).toBeFocused() +}) 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 9252a063af9..32a58440429 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 @@ -144,13 +144,6 @@ Then( }, ) -When('I close Agent v2 API key management', async function (this: DifyWorld) { - const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i }) - - await apiKeyDialog.getByLabel('Close').click() - await expect(apiKeyDialog).not.toBeVisible() -}) - When('I open the Agent v2 API Reference', async function (this: DifyWorld) { const page = this.getPage() const apiReferenceLink = page.getByRole('link', { name: 'API Reference' }) 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 12e3260d957..dabe1269416 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 @@ -1,16 +1,20 @@ import type { Page } from '@playwright/test' import type { DifyWorld } from '../../support/world' -import { Given, Then, When } from '@cucumber/cucumber' +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 { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers' 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)) + world.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) +} + Then('I should see the Agent v2 Web app access URL', async function (this: DifyWorld) { const webAppCard = getWebAppCard(this) @@ -20,13 +24,8 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible() }) -Then('I record the current Agent v2 orchestration draft', async function (this: DifyWorld) { - const draft = await getAgentComposerDraft(getCurrentAgentId(this)) - - this.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) -}) - When('I copy the Agent v2 Web app access URL', async function (this: DifyWorld) { + await recordComposerDraftSnapshot(this) await getWebAppCard(this).getByLabel('Copy access URL').click() }) @@ -35,6 +34,7 @@ Then('the Agent v2 Web app access URL should show it was copied', async function }) When('I launch the Agent v2 Web app', async function (this: DifyWorld) { + await recordComposerDraftSnapshot(this) const launchLink = getWebAppCard(this).getByRole('link', { name: 'Launch' }) const href = await launchLink.getAttribute('href') if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.') @@ -125,6 +125,7 @@ When('I close the Agent v2 Web app', async function (this: DifyWorld) { }) When('I open Agent v2 Embedded configuration', async function (this: DifyWorld) { + await recordComposerDraftSnapshot(this) await getWebAppCard(this).getByRole('button', { name: 'Embedded' }).click() }) @@ -137,6 +138,7 @@ Then('I should see the Agent v2 Embedded configuration dialog', async function ( }) When('I open Agent v2 Web app customization', async function (this: DifyWorld) { + await recordComposerDraftSnapshot(this) await getWebAppCard(this).getByRole('button', { name: 'Custom Frontend' }).click() }) @@ -149,6 +151,7 @@ Then('I should see the Agent v2 Web app customization dialog', async function (t }) When('I open Agent v2 Web app settings', async function (this: DifyWorld) { + await recordComposerDraftSnapshot(this) await getWebAppCard(this).getByRole('button', { name: 'Branding' }).click() }) @@ -172,66 +175,3 @@ Then( expect(JSON.stringify(draft.agent_soul ?? {})).toBe(snapshot) }, ) - -Given( - 'Agent v2 disabled Web app public unavailable state is available', - async function (this: DifyWorld) { - return skipBlockedPrecondition( - this, - 'Disabled Agent v2 Web app public URL does not expose a stable user-visible unavailable state; the current route redirects to Web app sign-in.', - { - owner: 'product', - remediation: - 'Define and implement the disabled public Web app UX before enabling this scenario.', - }, - ) - }, -) - -When('I open the disabled Agent v2 Web app URL', async function (this: DifyWorld) { - const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.') - if (!this.context) throw new Error('Playwright browser context has not been initialized.') - - const webAppPage = await this.context.newPage() - await webAppPage.goto(webAppURL) - - this.agentBuilder.accessPoint.webAppPage = webAppPage -}) - -Then( - 'the disabled Agent v2 Web app should show an unavailable state', - async function (this: DifyWorld) { - const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - - await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).toBeVisible({ - timeout: 30_000, - }) - await webAppPage.close() - this.agentBuilder.accessPoint.webAppPage = undefined - }, -) - -When('I open the restored Agent v2 Web app URL', async function (this: DifyWorld) { - const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.') - if (!this.context) throw new Error('Playwright browser context has not been initialized.') - - const webAppPage = await this.context.newPage() - await webAppPage.goto(webAppURL) - - this.agentBuilder.accessPoint.webAppPage = webAppPage -}) - -Then( - 'the restored Agent v2 Web app should not show an unavailable state', - async function (this: DifyWorld) { - const webAppPage = this.agentBuilder.accessPoint.webAppPage - if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.') - - await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).not.toBeVisible() - await webAppPage.close() - this.agentBuilder.accessPoint.webAppPage = undefined - }, -) 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 cdc4d01fbbb..61858c02dfb 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -3,7 +3,7 @@ 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 { getAgentAccessPath, publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' +import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, getAccessSurfaceCard, @@ -31,10 +31,6 @@ Given( }, ) -When('I open the Agent v2 Access Point page', async function (this: DifyWorld) { - await this.getPage().goto(getAgentAccessPath(getCurrentAgentId(this))) -}) - When( 'I open the preseeded Agent v2 Access Point page for {string} from the Agent Roster', async function (this: DifyWorld, agentName: string) { diff --git a/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts b/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts index b36f463f501..9d9d33044d0 100644 --- a/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts +++ b/e2e/features/step-definitions/agent-v2/advanced-settings.steps.ts @@ -1,21 +1,30 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { openAgentAdvancedSettings } from './configure-helpers' When('I expand Agent v2 Advanced Settings', async function (this: DifyWorld) { const page = this.getPage() const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' }) + const trigger = advancedSettings + .getByRole('heading', { name: 'Advanced Settings' }) + .getByRole('button') - await page.getByRole('button', { name: 'Advanced Settings' }).first().click() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })).toBeVisible() }) When('I collapse Agent v2 Advanced Settings', async function (this: DifyWorld) { const page = this.getPage() const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' }) + const trigger = advancedSettings + .getByRole('heading', { name: 'Advanced Settings' }) + .getByRole('button') - await page.getByRole('button', { name: 'Advanced Settings' }).first().click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })).not.toBeVisible() }) @@ -36,7 +45,7 @@ Then( Then( 'I should see the supported Agent v2 Advanced Settings entries', async function (this: DifyWorld) { - const advancedSettings = await openAgentAdvancedSettings(this.getPage()) + const advancedSettings = this.getPage().getByRole('region', { name: 'Advanced Settings' }) const envEditor = advancedSettings.getByRole('region', { name: 'Env Editor' }) await expect(envEditor).toBeVisible() 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 fdb03d61fdf..3615dde0c1b 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -1,6 +1,6 @@ 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 { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' import { getAgentComposerDraft, getTestAgent } from '../../agent-v2/support/agent' @@ -10,12 +10,7 @@ import { agentBuilderPreseededResources, } from '../../agent-v2/support/agent-builder-resources' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' -import { - asArray, - asRecord, - asString, - skipBlockedPrecondition, -} from '../../agent-v2/support/preflight/common' +import { asArray, asRecord, asString } from '../../agent-v2/support/preflight/common' import { agentBuilderTestMaterials } from '../../agent-v2/support/test-materials' import { expectProviderToolActionVisible, @@ -78,12 +73,7 @@ When( const copyName = createE2EResourceName('Agent', 'copy') await page.goto('/agents') - const card = page - .locator('article') - .filter({ - has: page.getByRole('link', { name: agentName }), - }) - .first() + const card = page.getByRole('article', { name: agentName, exact: true }) await expect(card).toBeVisible({ timeout: 30_000 }) await card.hover() @@ -268,26 +258,6 @@ Then('I should see the Agent v2 tool state fixture tools', async function (this: ) }) -async function skipToolCredentialErrorState(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 Tool credential error state is not covered: the current fixture only proves usable and not-authorized tool states.', - { - owner: 'seed/product', - remediation: - 'Define a stable invalid credential fixture and the expected user-visible error label before enabling this scenario.', - }, - ) -} - -Given('Agent v2 Tool credential error state is available', async function (this: DifyWorld) { - return skipToolCredentialErrorState(this) -}) - -Then('Agent v2 Tool credential error state should be available', async function (this: DifyWorld) { - return skipToolCredentialErrorState(this) -}) - Then('I should see the Agent v2 dual retrieval fixture settings', async function (this: DifyWorld) { const page = this.getPage() const knowledgeSection = page.getByRole('region', { name: 'Knowledge Retrieval' }) 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 62f414ee0f4..fd9b2cdce4e 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -22,13 +22,10 @@ import { updatedAgentPrompt, updatedAgentSoulConfig, } from '../../agent-v2/support/agent-soul' -import { asArray, asRecord, skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' -import { hasToolEntry } from '../../agent-v2/support/preflight/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath, } from '../../agent-v2/support/test-materials' -import { getPreseededToolContract } from '../../agent-v2/support/tools' import { expectAgentModelRequiredFeedback, getAgentEnvVariableValue, @@ -286,54 +283,6 @@ When('I apply the Agent v2 Build draft via API', async function (this: DifyWorld await applyAgentBuildDraft(getCurrentAgentId(this)) }) -async function skipBuildDraftToolWriteback(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Build chat Dify Tool writeback is not available: finalize/config mutation paths do not support tools; current passing coverage only verifies API-seeded Build draft apply for files, skills, and env.', - { - owner: 'product', - remediation: 'Define and implement Build draft Tool writeback before enabling this scenario.', - }, - ) -} - -Given('Agent v2 Build chat Dify Tool writeback is available', async function (this: DifyWorld) { - return skipBuildDraftToolWriteback(this) -}) - -Then( - 'Agent v2 Build chat Dify Tool writeback should be available', - async function (this: DifyWorld) { - return skipBuildDraftToolWriteback(this) - }, -) - -async function skipBuildDraftUnavailableResourceRecovery(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Build chat unavailable Skill/Tool recovery is not covered: the product needs a stable user-visible failure state and deterministic request fixture before this can be automated.', - { - owner: 'product/seed', - remediation: - 'Define the unavailable-resource UX contract, then seed a stable model-backed prompt that requests a missing Skill and Tool without mutating the saved Agent config.', - }, - ) -} - -Given( - 'Agent v2 Build chat unavailable Skill and Tool recovery is available', - async function (this: DifyWorld) { - return skipBuildDraftUnavailableResourceRecovery(this) - }, -) - -Then( - 'Agent v2 Build chat unavailable Skill and Tool recovery should be available', - async function (this: DifyWorld) { - return skipBuildDraftUnavailableResourceRecovery(this) - }, -) - Then('I should see the Agent v2 Build draft pending changes', async function (this: DifyWorld) { const page = this.getPage() @@ -438,26 +387,6 @@ Then( }, ) -Then( - 'the normal Agent v2 draft should not include the e2e-summary-skill Skill', - async function (this: DifyWorld) { - await expect - .poll( - async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - - return ( - agentSoul?.config_skills?.some( - (skill) => skill.name === agentBuilderPreseededResources.summarySkill, - ) ?? false - ) - }, - { timeout: 30_000 }, - ) - .toBe(false) - }, -) - Then( 'the normal Agent v2 draft should not include the generated build note', async function (this: DifyWorld) { @@ -471,26 +400,6 @@ Then( }, ) -Then( - 'the normal Agent v2 draft should not include the Agent Builder JSON Replace tool', - async function (this: DifyWorld) { - const agentId = getCurrentAgentId(this) - const tool = getPreseededToolContract(this, agentBuilderPreseededResources.jsonReplaceTool) - - await expect - .poll( - async () => { - const draft = await getAgentComposerDraft(agentId) - const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) - - return hasToolEntry(tools, tool) - }, - { timeout: 30_000 }, - ) - .toBe(false) - }, -) - Then( 'the Agent v2 draft should include the supported Build draft config', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index 2b5771417e7..79ec578c64e 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -161,16 +161,68 @@ export const uploadSummaryConfigSkillForBuildDraft = async (world: DifyWorld) => export const openAgentAdvancedSettings = async (page: ReturnType) => { const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' }) + const trigger = advancedSettings + .getByRole('heading', { name: 'Advanced Settings' }) + .getByRole('button') const envEditorHeading = advancedSettings.getByRole('heading', { name: 'Env Editor' }) - if (!(await envEditorHeading.isVisible().catch(() => false))) - await page.getByRole('button', { name: 'Advanced Settings' }).first().click() - + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') await expect(envEditorHeading).toBeVisible() return advancedSettings } +const findAgentEnvVariableRows = async (advancedSettings: Locator, key: string) => { + const matchingRows: Locator[] = [] + + for (const row of await advancedSettings.getByRole('row').all()) { + const keyInput = row.getByRole('textbox', { name: 'Key' }) + if ((await keyInput.count()) === 1 && (await keyInput.inputValue()) === key) + matchingRows.push(row) + } + + return matchingRows +} + +export const getAgentEnvVariableRow = async (advancedSettings: Locator, key: string) => { + let matchingRows: Locator[] = [] + + await expect + .poll( + async () => { + matchingRows = await findAgentEnvVariableRows(advancedSettings, key) + return matchingRows.length + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0) + + return matchingRows[0]! +} + +export const expectAgentEnvVariableAbsent = async (advancedSettings: Locator, key: string) => { + await expect + .poll(async () => (await findAgentEnvVariableRows(advancedSettings, key)).length) + .toBe(0) +} + +export const expectAgentEnvVariableRows = async ( + advancedSettings: Locator, + key: string, + value: string, +) => { + await getAgentEnvVariableRow(advancedSettings, key) + const variableRows = await findAgentEnvVariableRows(advancedSettings, key) + + for (const variableRow of variableRows) { + await expect(variableRow.getByRole('textbox', { name: 'Key' })).toHaveValue(key) + await expect(variableRow.getByRole('textbox', { name: 'Value' })).toHaveValue(value) + await expect(variableRow.getByText('Plain', { exact: true })).toBeVisible() + } +} + export const expectAgentEnvVariableVisible = async ( world: DifyWorld, key: string, @@ -178,44 +230,13 @@ export const expectAgentEnvVariableVisible = async ( ) => { const advancedSettings = await openAgentAdvancedSettings(world.getPage()) - await expect - .poll( - async () => { - const text = await advancedSettings.textContent() - const inputValues = await advancedSettings - .getByRole('textbox') - .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)) - - return { - hasKey: inputValues.includes(key) || !!text?.includes(key), - hasValue: inputValues.includes(value) || !!text?.includes(value), - } - }, - { timeout: 30_000 }, - ) - .toEqual({ - hasKey: true, - hasValue: true, - }) - await expect(advancedSettings.getByText('Plain', { exact: true })).toBeVisible() + await expectAgentEnvVariableRows(advancedSettings, key, value) } export const expectAgentEnvVariableHidden = async (world: DifyWorld, key: string) => { const advancedSettings = await openAgentAdvancedSettings(world.getPage()) - await expect - .poll( - async () => { - const text = await advancedSettings.textContent() - const inputValues = await advancedSettings - .getByRole('textbox') - .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)) - - return inputValues.includes(key) || !!text?.includes(key) - }, - { timeout: 30_000 }, - ) - .toBe(false) + await expectAgentEnvVariableAbsent(advancedSettings, key) } export const expectNormalAgentPromptDraft = async (world: DifyWorld) => { @@ -236,9 +257,11 @@ export const expectProviderToolActionVisible = async ( name: tool.providerName, }) await expect(provider).toBeVisible() + await expect(provider).toHaveAttribute('aria-expanded', 'false') + await provider.click() + await expect(provider).toHaveAttribute('aria-expanded', 'true') const action = toolsSection.getByText(tool.actionName, { exact: true }) - if (!(await action.isVisible())) await provider.click() await expect(action).toBeVisible() return { action, tool } 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 d304e96d5d8..38573073441 100644 --- a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts +++ b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts @@ -5,8 +5,11 @@ 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 { + expectAgentEnvVariableAbsent, expectAgentEnvVariableHidden, + expectAgentEnvVariableRows, expectAgentEnvVariableVisible, + getAgentEnvVariableRow, getAgentEnvVariables, getAgentEnvVariableValue, getCurrentAgentId, @@ -48,13 +51,16 @@ When( const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' }) await advancedSettings.getByRole('button', { name: 'Add environment variable' }).click() - await advancedSettings + const newVariableRow = await getAgentEnvVariableRow(advancedSettings, '') + await newVariableRow .getByRole('textbox', { name: 'Key' }) - .last() .fill(agentBuilderFixedInputs.envModeKey) - await advancedSettings + const savedVariableRow = await getAgentEnvVariableRow( + advancedSettings, + agentBuilderFixedInputs.envModeKey, + ) + await savedVariableRow .getByRole('textbox', { name: 'Value' }) - .last() .fill(agentBuilderFixedInputs.envModeValue) await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(2) }, @@ -231,22 +237,16 @@ Then( async function (this: DifyWorld) { const advancedSettings = await openAgentAdvancedSettings(this.getPage()) - await expect - .poll( - async () => - advancedSettings - .getByRole('textbox') - .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), - { timeout: 30_000 }, - ) - .toEqual( - expect.arrayContaining([ - agentBuilderFixedInputs.envPlainKey, - agentBuilderFixedInputs.envPlainValue, - agentBuilderFixedInputs.envModeKey, - agentBuilderFixedInputs.envModeValue, - ]), - ) + await expectAgentEnvVariableRows( + advancedSettings, + agentBuilderFixedInputs.envPlainKey, + agentBuilderFixedInputs.envPlainValue, + ) + await expectAgentEnvVariableRows( + advancedSettings, + agentBuilderFixedInputs.envModeKey, + agentBuilderFixedInputs.envModeValue, + ) await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(2) }, ) @@ -256,29 +256,12 @@ Then( async function (this: DifyWorld) { const advancedSettings = await openAgentAdvancedSettings(this.getPage()) - await expect - .poll( - async () => - advancedSettings - .getByRole('textbox') - .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), - { timeout: 30_000 }, - ) - .toEqual( - expect.arrayContaining([ - agentBuilderFixedInputs.envModeKey, - agentBuilderFixedInputs.envModeValue, - ]), - ) - await expect - .poll( - async () => - advancedSettings - .getByRole('textbox') - .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), - { timeout: 30_000 }, - ) - .not.toContain(agentBuilderFixedInputs.envPlainKey) + await expectAgentEnvVariableRows( + advancedSettings, + agentBuilderFixedInputs.envModeKey, + agentBuilderFixedInputs.envModeValue, + ) + await expectAgentEnvVariableAbsent(advancedSettings, agentBuilderFixedInputs.envPlainKey) await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(1) }, ) @@ -288,14 +271,18 @@ Then( async function (this: DifyWorld) { const page = this.getPage() const advancedSettings = await openAgentAdvancedSettings(page) - - await expect(advancedSettings.getByRole('textbox', { name: 'Key' })).toHaveValue( + const variableRow = await getAgentEnvVariableRow( + advancedSettings, agentBuilderFixedInputs.envPlainKey, ) - await expect(advancedSettings.getByRole('textbox', { name: 'Value' })).toHaveValue( + + await expect(variableRow.getByRole('textbox', { name: 'Key' })).toHaveValue( + agentBuilderFixedInputs.envPlainKey, + ) + await expect(variableRow.getByRole('textbox', { name: 'Value' })).toHaveValue( agentBuilderFixedInputs.envPlainValue, ) - await expect(advancedSettings.getByText('Plain', { exact: true })).toBeVisible() + await expect(variableRow.getByText('Plain', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible() }, ) @@ -324,22 +311,16 @@ Then( const page = this.getPage() const advancedSettings = await openAgentAdvancedSettings(page) - await expect - .poll( - async () => - advancedSettings - .getByRole('textbox') - .evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)), - { timeout: 30_000 }, - ) - .toEqual( - expect.arrayContaining([ - agentBuilderFixedInputs.envPlainKey, - agentBuilderFixedInputs.envPlainValue, - agentBuilderFixedInputs.envAfterInvalidImportKey, - agentBuilderFixedInputs.envAfterInvalidImportValue, - ]), - ) + await expectAgentEnvVariableRows( + advancedSettings, + agentBuilderFixedInputs.envPlainKey, + agentBuilderFixedInputs.envPlainValue, + ) + await expectAgentEnvVariableRows( + advancedSettings, + agentBuilderFixedInputs.envAfterInvalidImportKey, + agentBuilderFixedInputs.envAfterInvalidImportValue, + ) await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible() }, ) diff --git a/e2e/features/step-definitions/agent-v2/files.steps.ts b/e2e/features/step-definitions/agent-v2/files.steps.ts index 52399c31c72..e47b5a7fa65 100644 --- a/e2e/features/step-definitions/agent-v2/files.steps.ts +++ b/e2e/features/step-definitions/agent-v2/files.steps.ts @@ -1,7 +1,6 @@ import type { DifyWorld } from '../../support/world' -import { Given, Then, When } from '@cucumber/cucumber' +import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' import { agentBuilderTestMaterials } from '../../agent-v2/support/test-materials' import { expectAgentConfigFileHidden, @@ -124,45 +123,3 @@ Then( await expectAgentConfigFileSaved(this, 'specialFilename') }, ) - -async function skipUnsupportedFileFormatRejection(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 unsupported file format rejection is not stable: default upload configuration allows arbitrary extensions unless UPLOAD_FILE_EXTENSION_BLACKLIST is seeded.', - { - owner: 'product/seed', - remediation: - 'Define Agent config file type restrictions or seed UPLOAD_FILE_EXTENSION_BLACKLIST before enabling this scenario.', - }, - ) -} - -Given('Agent v2 unsupported file format rejection is available', async function (this: DifyWorld) { - return skipUnsupportedFileFormatRejection(this) -}) - -Then( - 'Agent v2 unsupported file format rejection should be available', - async function (this: DifyWorld) { - return skipUnsupportedFileFormatRejection(this) - }, -) - -async function skipOversizedFileRejection(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 oversized file rejection lacks a clear user-visible reason: the current upload dialog collapses upload and commit failures into a generic failure toast.', - { - owner: 'product', - remediation: 'Expose a stable user-visible file-size error before enabling this scenario.', - }, - ) -} - -Given('Agent v2 oversized file rejection is available', async function (this: DifyWorld) { - return skipOversizedFileRejection(this) -}) - -Then('Agent v2 oversized file rejection should be available', async function (this: DifyWorld) { - return skipOversizedFileRejection(this) -}) 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 c55e5598fab..790ac481543 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -1,10 +1,9 @@ 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 { Given, Then, When } from '@cucumber/cucumber' +import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { getWorkflowDraft } from '../../../support/api' -import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' const agentV2WorkflowNodeId = 'agent-v2' const taskFileOutputName = 'e2e_report.pdf' @@ -51,11 +50,12 @@ const waitForWorkflowDraftSave = (world: DifyWorld, appId: string) => const openWorkflowOutputVariablesPanel = async (world: DifyWorld) => { const page = world.getPage() + const outputVariablesButton = page.getByRole('button', { name: 'Output Variables' }) const newOutputButton = page.getByRole('button', { name: 'New output' }) - if (!(await newOutputButton.isVisible().catch(() => false))) - await page.getByRole('button', { name: 'Output Variables' }).click() - + await expect(outputVariablesButton).toHaveAttribute('aria-expanded', 'false') + await outputVariablesButton.click() + await expect(outputVariablesButton).toHaveAttribute('aria-expanded', 'true') await expect(newOutputButton).toBeVisible() } @@ -345,29 +345,3 @@ async function expectAgentTaskOutputReference( await expect(page.getByText('file', { exact: true })).toBeVisible() if (unexpectedName) await expect(page.getByText(unexpectedName, { exact: true })).toHaveCount(0) } - -async function skipWorkflowTaskOutputReferenceDeletionConsistency(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 workflow task output deletion consistency is not available: deleting an output from the list currently leaves the Prompt token without a stable user-visible invalid-reference state.', - { - owner: 'product', - remediation: - 'Define whether deletion should sync the Prompt token, block deletion, or expose an invalid-reference state before enabling this scenario.', - }, - ) -} - -Given( - 'Agent v2 workflow task output reference deletion consistency is available', - async function (this: DifyWorld) { - return skipWorkflowTaskOutputReferenceDeletionConsistency(this) - }, -) - -Then( - 'Agent v2 workflow task output reference deletion consistency should be available', - async function (this: DifyWorld) { - return skipWorkflowTaskOutputReferenceDeletionConsistency(this) - }, -) diff --git a/e2e/features/step-definitions/agent-v2/preflight.steps.ts b/e2e/features/step-definitions/agent-v2/preflight.steps.ts index 89e3d2515d2..5f5747cd5ab 100644 --- a/e2e/features/step-definitions/agent-v2/preflight.steps.ts +++ b/e2e/features/step-definitions/agent-v2/preflight.steps.ts @@ -17,7 +17,6 @@ import { } from '../../agent-v2/support/preflight/agents' import { skipMissingIndexingPreseededDataset, - skipMissingPreseededDataset, skipMissingReadyPreseededDataset, } from '../../agent-v2/support/preflight/datasets' import { @@ -81,16 +80,6 @@ Given( }, ) -Given( - 'the Agent Builder preseeded dataset {string} is available', - async function (this: DifyWorld, resourceName: string) { - const resource = await skipMissingPreseededDataset(this, resourceName) - if (resource === 'skipped') return resource - - this.agentBuilder.preflight.preseededResources[resourceName] = resource - }, -) - Given( 'the Agent Builder preseeded dataset {string} is indexed and ready', async function (this: DifyWorld, resourceName: string) { 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 89075a2fbda..cbe375c180a 100644 --- a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts +++ b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts @@ -36,8 +36,7 @@ Given( When('I open the Agent v2 workflow node panel', async function (this: DifyWorld) { const page = this.getPage() - const workflowCanvas = page.locator('#workflow-container') - const agentNode = workflowCanvas.getByRole('button', { name: 'Agent' }).first() + const agentNode = page.getByRole('button', { name: 'Agent', exact: true }) await expect(agentNode).toBeVisible({ timeout: 30_000 }) await agentNode.click() diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index d6fd5bd3729..ee5824c1074 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -16,8 +16,8 @@ When('I enter a unique E2E app name', async function (this: DifyWorld) { When('I confirm app creation', async function (this: DifyWorld) { const createButton = this.getPage() + .getByRole('dialog') .getByRole('button', { name: /^Create(?:\s|$)/ }) - .last() await expect(createButton).toBeEnabled() await createButton.click() @@ -25,12 +25,9 @@ When('I confirm app creation', async function (this: DifyWorld) { When('I select the {string} app type', async function (this: DifyWorld, appType: string) { const dialog = this.getPage().getByRole('dialog') - // The modal defaults to ADVANCED_CHAT, so the preview panel immediately renders - //

Chatflow

alongside the card's
Chatflow
. - // locator('div').getByText(...) would still match the

because getByText - // searches inside each div for any descendant. Use :text-is() instead, which - // targets only
elements whose own normalised text equals appType exactly. - const appTypeCard = dialog.locator(`div:text-is("${appType}")`) + const appTypeCard = dialog.getByRole('button', { + name: new RegExp(`^${appType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`), + }) await expect(appTypeCard).toBeVisible() await appTypeCard.click() diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index bc5fd2ddee0..b347ddb0216 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -17,14 +17,9 @@ When('I open the options menu for the last created E2E app', async function (thi const page = this.getPage() const appLink = page.getByRole('link', { name: appName, exact: true }) - const appCard = page - .locator('div') - .filter({ has: appLink }) - .filter({ has: page.getByRole('button', { name: 'More' }) }) - .last() await expect(appLink).toBeVisible() - await appCard.hover() - await appCard.getByRole('button', { name: 'More' }).click() + await appLink.hover() + await page.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click() }) When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) { diff --git a/e2e/features/step-definitions/apps/publish-app.steps.ts b/e2e/features/step-definitions/apps/publish-app.steps.ts index c426bc4c5a1..aca00b220e4 100644 --- a/e2e/features/step-definitions/apps/publish-app.steps.ts +++ b/e2e/features/step-definitions/apps/publish-app.steps.ts @@ -3,7 +3,7 @@ import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' When('I open the publish panel', async function (this: DifyWorld) { - await this.getPage().getByRole('button', { name: 'Publish' }).first().click() + await this.getPage().getByRole('button', { name: 'Publish', exact: true }).click() }) When('I publish the app', 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 2a99151f720..b216b518696 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -21,12 +21,17 @@ When('I enable the Web App share', async function (this: DifyWorld) { } await page.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click() - await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 }) - await page.getByRole('switch').first().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) { - await expect(this.getPage().getByText('In Service').first()).toBeVisible({ timeout: 10_000 }) + 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) { diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts index c225591d691..d9cf64bb9c3 100644 --- a/e2e/features/step-definitions/apps/workflow-run.steps.ts +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -21,5 +21,7 @@ When('I run the workflow', async function (this: DifyWorld) { Then('the workflow run should succeed', async function (this: DifyWorld) { const page = this.getPage() await page.getByText('DETAIL', { exact: true }).click() - await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 }) + await expect(page.getByRole('status').getByText('SUCCESS', { exact: true })).toBeVisible({ + timeout: 55_000, + }) }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index ebafbb9892a..5077834a6ea 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -6,14 +6,14 @@ import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber' -import { chromium } from '@playwright/test' +import { chromium, webkit } from '@playwright/test' import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' import { deleteTestApp } from '../../support/api' -import { runCleanupTasks } from '../../support/cleanup' +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 } from '../../test-env' +import { baseURL, cucumberHeadless, cucumberSlowMo, e2eBrowser } from '../../test-env' import { deleteTestAgent } from '../agent-v2/support/agent' import { deleteAgentConfigFile, @@ -91,12 +91,13 @@ const captureDiagnosticPage = async ( BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => { await mkdir(artifactsDir, { recursive: true }) - browser = await chromium.launch({ + const browserType = e2eBrowser === 'webkit' ? webkit : chromium + browser = await browserType.launch({ headless: cucumberHeadless, slowMo: cucumberSlowMo, }) - console.warn(`[e2e] session cache bootstrap against ${baseURL}`) + console.warn(`[e2e] ${e2eBrowser} session cache bootstrap against ${baseURL}`) await ensureAuthenticatedState(browser, baseURL) }) @@ -120,6 +121,9 @@ Before(async function (this: DifyWorld, { pickle }) { const scenarioTags = pickle.tags.map((tag) => tag.name) const isMicrophoneScenario = scenarioTags.includes('@microphone') const isUnauthenticatedScenario = scenarioTags.includes('@unauthenticated') + if (isMicrophoneScenario && e2eBrowser !== 'chromium') + throw new Error('Microphone scenarios require E2E_BROWSER=chromium.') + const scenarioBrowser = isMicrophoneScenario ? await getMicrophoneBrowser() : browser if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(scenarioBrowser) @@ -151,7 +155,7 @@ After( const message = `Cleanup errors:\n${closeErrors.join('\n')}` this.attach(message, 'text/plain') - if (result?.status === Status.PASSED) throw new Error(message) + if (shouldFailForCleanupErrors(result?.status)) throw new Error(message) }, ) @@ -196,7 +200,7 @@ After( const message = `Cleanup errors:\n${cleanupErrors.join('\n')}` this.attach(message, 'text/plain') - if (result?.status === Status.PASSED) throw new Error(message) + if (shouldFailForCleanupErrors(result?.status)) throw new Error(message) }, ) diff --git a/e2e/package.json b/e2e/package.json index 0f0e9f71553..f6e1b3bd099 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,7 @@ "e2e:full": "tsx ./scripts/run-cucumber.ts --full", "e2e:full:headed": "tsx ./scripts/run-cucumber.ts --full --headed", "e2e:headed": "tsx ./scripts/run-cucumber.ts --headed", - "e2e:install": "playwright install --with-deps chromium", + "e2e:install": "playwright install --with-deps chromium webkit", "e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down", "e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up", "e2e:reset": "tsx ./scripts/setup.ts reset", diff --git a/e2e/scripts/env.ts b/e2e/scripts/env.ts index 5d33516426f..0302265259c 100644 --- a/e2e/scripts/env.ts +++ b/e2e/scripts/env.ts @@ -81,6 +81,7 @@ export const validateE2eEnv = () => E2E_AGENT_DECISION_MODEL_TYPE: process.env.E2E_AGENT_DECISION_MODEL_TYPE, E2E_API_URL: process.env.E2E_API_URL, E2E_BASE_URL: process.env.E2E_BASE_URL, + E2E_BROWSER: process.env.E2E_BROWSER, E2E_BROKEN_MODEL_NAME: process.env.E2E_BROKEN_MODEL_NAME, E2E_BROKEN_MODEL_PROVIDER: process.env.E2E_BROKEN_MODEL_PROVIDER, E2E_BROKEN_MODEL_TYPE: process.env.E2E_BROKEN_MODEL_TYPE, @@ -123,6 +124,7 @@ export const validateE2eEnv = () => E2E_AGENT_DECISION_MODEL_TYPE: z.string().min(1).optional(), E2E_API_URL: z.url().optional(), E2E_BASE_URL: z.url().optional(), + E2E_BROWSER: z.enum(['chromium', 'webkit']).optional(), E2E_BROKEN_MODEL_NAME: z.string().min(1).optional(), E2E_BROKEN_MODEL_PROVIDER: z.string().min(1).optional(), E2E_BROKEN_MODEL_TYPE: z.string().min(1).optional(), diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index 1b26926c53f..93c05e10d15 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -1,6 +1,13 @@ import type { ManagedProcess } from '../support/process' import { mkdir, readFile, rm } from 'node:fs/promises' import path from 'node:path' +import { runCleanupTasks } from '../support/cleanup' +import { + assertCucumberReport, + formatCucumberReportSummary, + getCucumberReportGate, + readCucumberReportSummary, +} from '../support/cucumber-report' import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process' import { startWebServer, stopWebServer } from '../support/web-server' import { apiURL, baseURL, reuseExistingWebServer } from '../test-env' @@ -150,19 +157,17 @@ const main = async () => { const cleanup = async () => { if (!cleanupPromise) { cleanupPromise = (async () => { - await stopWebServer() - await stopManagedProcess(celeryProcess) - await stopManagedProcess(apiProcess) - await stopManagedProcess(difyAgentProcess) - await stopManagedProcess(shellctlProcess) + const cleanupErrors = await runCleanupTasks([ + { label: 'Stop web server', run: stopWebServer }, + { label: 'Stop celery worker', run: () => stopManagedProcess(celeryProcess) }, + { label: 'Stop API server', run: () => stopManagedProcess(apiProcess) }, + { label: 'Stop agent backend', run: () => stopManagedProcess(difyAgentProcess) }, + { label: 'Stop shellctl sandbox', run: () => stopManagedProcess(shellctlProcess) }, + ...(startMiddlewareForRun ? [{ label: 'Stop middleware', run: stopMiddleware }] : []), + ]) - if (startMiddlewareForRun) { - try { - await stopMiddleware() - } catch { - // Cleanup should continue even if middleware shutdown fails. - } - } + if (cleanupErrors.length > 0) + throw new Error(`E2E teardown errors:\n${cleanupErrors.join('\n')}`) })() } @@ -170,9 +175,13 @@ const main = async () => { } const onTerminate = () => { - void cleanup().finally(() => { - process.exit(1) - }) + void cleanup() + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + }) + .finally(() => { + process.exit(1) + }) } process.once('SIGINT', onTerminate) @@ -265,6 +274,16 @@ const main = async () => { env: cucumberEnv, }) + const reportGate = getCucumberReportGate(cucumberEnv) + if (reportGate) { + const reportPath = path.join(cucumberReportDir, 'report.json') + const reportSummary = await readCucumberReportSummary(reportPath) + console.warn( + `[e2e] cucumber report ${reportGate.profile}: ${formatCucumberReportSummary(reportSummary)}`, + ) + assertCucumberReport(reportSummary, reportGate) + } + process.exitCode = result.exitCode } finally { process.off('SIGINT', onTerminate) diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index 4bf42ca0a91..c8478290f19 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -55,9 +55,13 @@ const middlewareDataPaths = [ const e2eStatePaths = [ path.join(e2eDir, '.auth'), path.join(e2eDir, 'cucumber-report'), + path.join(e2eDir, 'cucumber-report-non-external'), + path.join(e2eDir, 'cucumber-report-webkit'), path.join(e2eDir, '.logs'), path.join(e2eDir, '.logs-non-external'), + path.join(e2eDir, '.logs-webkit'), path.join(e2eDir, 'playwright-report'), + path.join(e2eDir, 'seed-report'), path.join(e2eDir, 'test-results'), ] diff --git a/e2e/support/cleanup.ts b/e2e/support/cleanup.ts index 147c59a1f79..6bb85ab90ba 100644 --- a/e2e/support/cleanup.ts +++ b/e2e/support/cleanup.ts @@ -3,6 +3,9 @@ export type CleanupTask = { run: () => Promise | void } +export const shouldFailForCleanupErrors = (status: string | undefined) => + status === 'PASSED' || status === 'SKIPPED' + export async function runCleanupTasks(tasks: CleanupTask[]): Promise { const errors: string[] = [] diff --git a/e2e/support/cucumber-report.ts b/e2e/support/cucumber-report.ts new file mode 100644 index 00000000000..4fd582b9f76 --- /dev/null +++ b/e2e/support/cucumber-report.ts @@ -0,0 +1,279 @@ +import { Buffer } from 'node:buffer' +import { readFile } from 'node:fs/promises' + +type CucumberEmbedding = { + data?: string + mime_type?: string +} + +type CucumberStep = { + embeddings?: CucumberEmbedding[] + hidden?: boolean + result?: { + status?: string + } +} + +type CucumberScenario = { + name?: string + steps?: CucumberStep[] + tags?: { name?: string }[] + type?: string +} + +export type CucumberReport = { + elements?: CucumberScenario[] + uri?: string +}[] + +export type CucumberReportSummary = { + blockedScenarios: { + name: string + tags: string[] + uri: string + }[] + blockedSkipped: number + failed: number + other: number + passed: number + selected: number + skipped: number + unexpectedSkipped: number +} + +export type CucumberReportGate = { + allowedBlockedScenarios: Record + maxSkipped: number + maxUnexpectedSkipped: number + minPassed: number + minSelected: number + profile: string +} + +const reportGateProfiles = { + core: { + allowedBlockedScenarios: { + 'features/agent-v2/access-point.feature': ['Workflow access shows the referencing workflow'], + 'features/agent-v2/advanced-settings.feature': [ + 'Content Moderation keyword preset replies are saved and restored', + ], + 'features/agent-v2/agent-edit.feature': [ + 'Saved orchestration sections are visible on the Agent Edit page', + 'Duplicated Agent inherits configuration without changing the original Agent', + 'Tool states are visible on the Agent Edit page', + 'Dual Knowledge Retrieval settings are visible on the Agent Edit page', + 'Agent Edit opens the same Agent in Agent Console', + ], + 'features/agent-v2/configure-persistence.feature': [ + 'Selecting a stable model in Configure persists after refresh', + 'Persisted Agent v2 instructions remain visible after refresh', + ], + 'features/agent-v2/knowledge.feature': [ + 'Agent decide Knowledge Retrieval settings are saved and restored', + 'Custom query Knowledge Retrieval settings are saved and restored', + 'Removing Knowledge Retrieval clears the saved dataset reference', + ], + 'features/agent-v2/output-variables.feature': [ + 'Workflow Agent v2 output variables persist after refresh', + 'Workflow Agent v2 nested object output variables persist after refresh', + 'Workflow Agent v2 prompt output reference stays synced when renamed', + ], + 'features/agent-v2/preflight.feature': [ + 'Stable chat model is available', + 'Default speech-to-text model is available', + 'Agent-decision chat model is available', + 'Broken chat model is available for recovery scenarios', + 'JSON Replace tool is available', + 'Tavily Search tool is available', + 'Agent knowledge base is available', + 'Indexing knowledge base is available', + 'Full config Agent is available', + 'Full config Agent includes the summary Skill', + 'Full config Agent includes core fixture configuration', + 'Content Moderation Settings is enabled', + 'Tool states Agent is available', + 'Tool states Agent includes tool state fixture configuration', + 'OAuth2 tool Agent includes credential fixture configuration', + 'Dual retrieval Agent is available', + 'Dual retrieval Agent includes dual retrieval fixture configuration', + 'Published Web app Agent exposes Web app access', + 'Backend API-enabled Agent is available', + 'Backend API-enabled Agent exposes API access with a key', + 'Workflow reference Agent is available', + 'Reference workflow is available', + 'Workflow reference Agent is used by the reference workflow', + ], + 'features/agent-v2/publish.feature': [ + 'Publish a configured Agent v2 draft', + 'Publish action follows unpublished changes', + 'Published Agent v2 version remains isolated from draft edits', + 'Restoring a published Agent v2 version shows the restored configuration in Builder', + ], + 'features/agent-v2/tools.feature': [ + 'JSON Replace tool is saved after adding it from the Tools selector', + 'OAuth2 tool credentials stay authorized after Configure autosaves', + ], + }, + maxSkipped: 44, + maxUnexpectedSkipped: 0, + minPassed: 65, + minSelected: 109, + }, + external: { + allowedBlockedScenarios: {}, + maxSkipped: 0, + maxUnexpectedSkipped: 0, + minPassed: 11, + minSelected: 11, + }, + 'webkit-browser-smoke': { + allowedBlockedScenarios: {}, + maxSkipped: 0, + maxUnexpectedSkipped: 0, + minPassed: 4, + minSelected: 4, + }, +} satisfies Record> + +export const getCucumberReportGate = (env: NodeJS.ProcessEnv): CucumberReportGate | undefined => { + const profile = env.E2E_CUCUMBER_REPORT_PROFILE?.trim() + if (!profile) return undefined + + const gate = reportGateProfiles[profile as keyof typeof reportGateProfiles] + if (!gate) throw new Error(`Unknown Cucumber report gate profile "${profile}".`) + + return { + ...gate, + allowedBlockedScenarios: Object.fromEntries( + Object.entries(gate.allowedBlockedScenarios).map(([uri, names]) => [uri, [...names]]), + ), + profile, + } +} + +const failureStatuses = new Set(['ambiguous', 'failed', 'pending', 'undefined', 'unknown']) + +const hasBlockedPrecondition = (steps: CucumberStep[]) => + steps + .filter((step) => !step.hidden && step.result?.status?.toLowerCase() === 'skipped') + .flatMap((step) => step.embeddings || []) + .filter((embedding) => embedding.mime_type === 'text/plain' && embedding.data) + .some((embedding) => { + const contents = Buffer.from(embedding.data || '', 'base64').toString('utf8') + return contents.startsWith('Blocked precondition:') + }) + +export const summarizeCucumberReport = (report: CucumberReport): CucumberReportSummary => { + const summary: CucumberReportSummary = { + blockedScenarios: [], + blockedSkipped: 0, + failed: 0, + other: 0, + passed: 0, + selected: 0, + skipped: 0, + unexpectedSkipped: 0, + } + + for (const feature of report) { + for (const scenario of feature.elements || []) { + if (scenario.type !== 'scenario') continue + + summary.selected += 1 + const steps = scenario.steps || [] + const statuses = steps + .map((step) => step.result?.status?.toLowerCase()) + .filter((status): status is string => Boolean(status)) + + if (statuses.some((status) => failureStatuses.has(status))) { + summary.failed += 1 + continue + } + + if (steps.length === 0 || statuses.length !== steps.length) { + summary.other += 1 + continue + } + + if (statuses.includes('skipped')) { + summary.skipped += 1 + if (hasBlockedPrecondition(steps)) { + summary.blockedSkipped += 1 + summary.blockedScenarios.push({ + name: scenario.name || '', + tags: (scenario.tags || []).flatMap((tag) => (tag.name ? [tag.name] : [])), + uri: feature.uri || '', + }) + } else summary.unexpectedSkipped += 1 + continue + } + + if (statuses.length > 0 && statuses.every((status) => status === 'passed')) { + summary.passed += 1 + continue + } + + summary.other += 1 + } + } + + return summary +} + +export const readCucumberReportSummary = async (reportPath: string) => { + const contents = await readFile(reportPath, 'utf8') + const report = JSON.parse(contents) as CucumberReport + return summarizeCucumberReport(report) +} + +export const assertCucumberReport = (summary: CucumberReportSummary, gate: CucumberReportGate) => { + const errors: string[] = [] + const allowedBlockedScenarios = new Set( + Object.entries(gate.allowedBlockedScenarios).flatMap(([uri, names]) => + names.map((name) => `${uri}\0${name}`), + ), + ) + const disallowedBlockedScenarios = summary.blockedScenarios.filter( + (scenario) => !allowedBlockedScenarios.has(`${scenario.uri}\0${scenario.name}`), + ) + + if (summary.selected < gate.minSelected) + errors.push(`selected scenarios ${summary.selected} is below minimum ${gate.minSelected}`) + if (summary.passed < gate.minPassed) + errors.push(`passed scenarios ${summary.passed} is below minimum ${gate.minPassed}`) + if (summary.skipped > gate.maxSkipped) + errors.push(`skipped scenarios ${summary.skipped} exceeds maximum ${gate.maxSkipped}`) + if (summary.unexpectedSkipped > gate.maxUnexpectedSkipped) { + errors.push( + `unexpected skipped scenarios ${summary.unexpectedSkipped} exceeds maximum ${gate.maxUnexpectedSkipped}`, + ) + } + if (disallowedBlockedScenarios.length > 0) { + errors.push( + `blocked scenarios not present in the checked-in allowlist: ${disallowedBlockedScenarios + .map((scenario) => `${scenario.uri}: ${scenario.name}`) + .join(', ')}`, + ) + } + if (summary.failed > 0) errors.push(`failed scenarios ${summary.failed} exceeds maximum 0`) + if (summary.other > 0) errors.push(`unclassified scenarios ${summary.other} exceeds maximum 0`) + + if (errors.length > 0) + throw new Error( + [ + `Cucumber report gate "${gate.profile}" failed:`, + ...errors.map((error) => `- ${error}`), + ].join('\n'), + ) +} + +export const formatCucumberReportSummary = (summary: CucumberReportSummary) => + [ + `selected=${summary.selected}`, + `passed=${summary.passed}`, + `skipped=${summary.skipped}`, + `blockedSkipped=${summary.blockedSkipped}`, + `unexpectedSkipped=${summary.unexpectedSkipped}`, + `failed=${summary.failed}`, + `other=${summary.other}`, + ].join(' ') diff --git a/e2e/test-env.ts b/e2e/test-env.ts index c0afc2a8c1f..0af395909b9 100644 --- a/e2e/test-env.ts +++ b/e2e/test-env.ts @@ -2,11 +2,22 @@ export const defaultBaseURL = 'http://127.0.0.1:3000' export const defaultApiURL = 'http://127.0.0.1:5001' export const defaultLocale = 'en-US' +export const supportedE2EBrowsers = ['chromium', 'webkit'] as const +export type E2EBrowser = (typeof supportedE2EBrowsers)[number] + +export const resolveE2EBrowser = (value: string | undefined): E2EBrowser => { + if (value === undefined) return 'chromium' + if (supportedE2EBrowsers.some((browser) => browser === value)) return value as E2EBrowser + + throw new Error(`Unsupported E2E browser "${value}".`) +} + export const baseURL = process.env.E2E_BASE_URL || defaultBaseURL export const apiURL = process.env.E2E_API_URL || defaultApiURL export const cucumberHeadless = process.env.CUCUMBER_HEADLESS !== '0' export const cucumberSlowMo = Number(process.env.E2E_SLOW_MO || 0) +export const e2eBrowser = resolveE2EBrowser(process.env.E2E_BROWSER) export const reuseExistingWebServer = process.env.E2E_REUSE_WEB_SERVER ? process.env.E2E_REUSE_WEB_SERVER !== '0' : !process.env.CI diff --git a/e2e/tests/cleanup.test.ts b/e2e/tests/cleanup.test.ts index 30eb0a37b73..486cb676f38 100644 --- a/e2e/tests/cleanup.test.ts +++ b/e2e/tests/cleanup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { runCleanupTasks } from '../support/cleanup' +import { runCleanupTasks, shouldFailForCleanupErrors } from '../support/cleanup' describe('runCleanupTasks', () => { it('runs every task in order', async () => { @@ -47,3 +47,16 @@ describe('runCleanupTasks', () => { expect(errors).toEqual(['sync cleanup: sync failure', 'async cleanup: async failure']) }) }) + +describe('shouldFailForCleanupErrors', () => { + it.each(['PASSED', 'SKIPPED'])('fails a %s scenario when cleanup fails', (status) => { + expect(shouldFailForCleanupErrors(status)).toBe(true) + }) + + it.each(['FAILED', 'AMBIGUOUS', 'PENDING', 'UNDEFINED', 'UNKNOWN'])( + 'preserves an existing %s result when cleanup fails', + (status) => { + expect(shouldFailForCleanupErrors(status)).toBe(false) + }, + ) +}) diff --git a/e2e/tests/cucumber-report.test.ts b/e2e/tests/cucumber-report.test.ts new file mode 100644 index 00000000000..473ade1b814 --- /dev/null +++ b/e2e/tests/cucumber-report.test.ts @@ -0,0 +1,214 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' +import { + assertCucumberReport, + getCucumberReportGate, + summarizeCucumberReport, +} from '../support/cucumber-report' + +const step = (status?: string, options: { blockedReason?: string; hidden?: boolean } = {}) => ({ + ...(options.blockedReason + ? { + embeddings: [ + { + data: Buffer.from(`Blocked precondition: ${options.blockedReason}`).toString('base64'), + mime_type: 'text/plain', + }, + ], + } + : {}), + ...(options.hidden ? { hidden: true } : {}), + ...(status ? { result: { status } } : {}), +}) + +const scenario = (name: string, steps: ReturnType[], tags: string[] = []) => ({ + name, + steps, + tags: tags.map((tag) => ({ name: tag })), + type: 'scenario', +}) + +describe('summarizeCucumberReport', () => { + it('separates passed, explicitly blocked, and unexpected skipped scenarios', () => { + const summary = summarizeCucumberReport([ + { + elements: [ + scenario('passes', [step('passed')]), + scenario( + 'blocked', + [step('passed'), step('skipped', { blockedReason: 'fixture' })], + ['@fixture'], + ), + scenario('unexpected skip', [step('skipped')]), + ], + uri: 'features/example.feature', + }, + ]) + + expect(summary).toEqual({ + blockedScenarios: [ + { + name: 'blocked', + tags: ['@fixture'], + uri: 'features/example.feature', + }, + ], + blockedSkipped: 1, + failed: 0, + other: 0, + passed: 1, + selected: 3, + skipped: 2, + unexpectedSkipped: 1, + }) + }) + + it('treats a failed hook as a failed scenario', () => { + const summary = summarizeCucumberReport([ + { + elements: [scenario('cleanup fails', [step('passed'), step('failed', { hidden: true })])], + uri: 'features/example.feature', + }, + ]) + + expect(summary.failed).toBe(1) + expect(summary.passed).toBe(0) + }) + + it('classifies a scenario with a missing step status as unclassified', () => { + const summary = summarizeCucumberReport([ + { + elements: [scenario('missing status', [step('passed', { hidden: true }), step()])], + uri: 'features/example.feature', + }, + ]) + + expect(summary.other).toBe(1) + expect(summary.passed).toBe(0) + }) +}) + +describe('assertCucumberReport', () => { + it('accepts an explicitly blocked readiness report within configured limits', () => { + const summary = { + blockedScenarios: [ + { name: 'fixture blocked', tags: ['@fixture'], uri: 'features/example.feature' }, + { name: 'preflight blocked', tags: ['@preflight'], uri: 'features/example.feature' }, + ], + blockedSkipped: 2, + failed: 0, + other: 0, + passed: 3, + selected: 5, + skipped: 2, + unexpectedSkipped: 0, + } + + expect(() => + assertCucumberReport(summary, { + allowedBlockedScenarios: { + 'features/example.feature': ['fixture blocked', 'preflight blocked'], + }, + maxSkipped: 2, + maxUnexpectedSkipped: 0, + minPassed: 3, + minSelected: 5, + profile: 'core', + }), + ).not.toThrow() + }) + + it('rejects zero coverage, all-skipped coverage, and unexplained skips', () => { + const summary = { + blockedScenarios: [ + { name: 'fixture blocked', tags: ['@fixture'], uri: 'features/example.feature' }, + ], + blockedSkipped: 1, + failed: 0, + other: 0, + passed: 0, + selected: 2, + skipped: 2, + unexpectedSkipped: 1, + } + + expect(() => + assertCucumberReport(summary, { + allowedBlockedScenarios: { + 'features/example.feature': ['fixture blocked'], + }, + maxSkipped: 2, + maxUnexpectedSkipped: 0, + minPassed: 1, + minSelected: 3, + profile: 'external', + }), + ).toThrow( + [ + 'Cucumber report gate "external" failed:', + '- selected scenarios 2 is below minimum 3', + '- passed scenarios 0 is below minimum 1', + '- unexpected skipped scenarios 1 exceeds maximum 0', + ].join('\n'), + ) + }) + + it('rejects a blocked scenario that replaces an allowed scenario with the same dependency tag', () => { + const summary = { + blockedScenarios: [ + { name: 'core regression', tags: ['@fixture'], uri: 'features/example.feature' }, + ], + blockedSkipped: 1, + failed: 0, + other: 0, + passed: 1, + selected: 2, + skipped: 1, + unexpectedSkipped: 0, + } + + expect(() => + assertCucumberReport(summary, { + allowedBlockedScenarios: { + 'features/example.feature': ['fixture blocked'], + }, + maxSkipped: 1, + maxUnexpectedSkipped: 0, + minPassed: 1, + minSelected: 2, + profile: 'core', + }), + ).toThrow( + 'blocked scenarios not present in the checked-in allowlist: features/example.feature: core regression', + ) + }) +}) + +describe('getCucumberReportGate', () => { + it('returns no gate unless a profile is configured', () => { + expect(getCucumberReportGate({})).toBeUndefined() + }) + + it('returns the checked-in gate for a known profile', () => { + expect( + getCucumberReportGate({ + E2E_CUCUMBER_REPORT_PROFILE: 'webkit-browser-smoke', + }), + ).toEqual({ + allowedBlockedScenarios: {}, + maxSkipped: 0, + maxUnexpectedSkipped: 0, + minPassed: 4, + minSelected: 4, + profile: 'webkit-browser-smoke', + }) + }) + + it('rejects an unknown profile instead of silently weakening the gate', () => { + expect(() => + getCucumberReportGate({ + E2E_CUCUMBER_REPORT_PROFILE: 'custom', + }), + ).toThrow('Unknown Cucumber report gate profile "custom".') + }) +}) diff --git a/e2e/tests/test-env.test.ts b/e2e/tests/test-env.test.ts new file mode 100644 index 00000000000..ae3c530d83a --- /dev/null +++ b/e2e/tests/test-env.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { resolveE2EBrowser } from '../test-env' + +describe('resolveE2EBrowser', () => { + it('uses Chromium by default', () => { + expect(resolveE2EBrowser(undefined)).toBe('chromium') + }) + + it.each(['chromium', 'webkit'] as const)('accepts %s', (browser) => { + expect(resolveE2EBrowser(browser)).toBe(browser) + }) + + it('rejects unsupported browsers', () => { + expect(() => resolveE2EBrowser('firefox')).toThrow('Unsupported E2E browser "firefox".') + }) +}) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index e0322e58f10..b38ecb92b74 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -10,7 +10,8 @@ * - Access mode icons */ import type { App } from '@/types/app' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AppCard } from '@/app/components/apps/app-card' @@ -278,8 +279,14 @@ const renderAppCard = (app?: Partial) => { }) } -const openOperationsMenu = () => { - fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) +const openOperationsMenu = async (appName = 'Test Chat App') => { + const user = userEvent.setup() + await user.click( + screen.getByRole('button', { + name: `common.operation.moreActionsFor:{"name":"${appName}"}`, + }), + ) + return user } describe('App Card Operations Flow', () => { @@ -334,15 +341,15 @@ describe('App Card Operations Flow', () => { it('should show delete confirmation and call API on confirm', async () => { renderAppCard({ id: 'app-to-delete', name: 'Deletable App' }) - openOperationsMenu() - fireEvent.click(await screen.findByText('common.operation.delete')) + const user = await openOperationsMenu('Deletable App') + await user.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) await waitFor(() => { expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument() }) - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + await user.type(screen.getByRole('textbox'), 'Deletable App') + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete') @@ -355,9 +362,9 @@ describe('App Card Operations Flow', () => { it('should open edit modal and call updateAppInfo on confirm', async () => { renderAppCard({ id: 'app-edit', name: 'Editable App' }) - openOperationsMenu() - fireEvent.click(await screen.findByText('app.editApp')) - fireEvent.click(await screen.findByTestId('confirm-edit')) + const user = await openOperationsMenu('Editable App') + await user.click(await screen.findByRole('menuitem', { name: 'app.editApp' })) + await user.click(await screen.findByRole('button', { name: 'Confirm' })) await waitFor(() => { expect(updateAppInfo).toHaveBeenCalledWith( @@ -375,8 +382,8 @@ describe('App Card Operations Flow', () => { it('should call exportAppConfig for completion apps', async () => { renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' }) - openOperationsMenu() - fireEvent.click(await screen.findByText('app.export')) + const user = await openOperationsMenu('Export App') + await user.click(await screen.findByRole('menuitem', { name: 'app.export' })) await waitFor(() => { expect(exportAppConfig).toHaveBeenCalledWith( @@ -392,7 +399,9 @@ describe('App Card Operations Flow', () => { renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] }) expect( - screen.queryByRole('button', { name: 'common.operation.more' }), + screen.queryByRole('button', { + name: /common\.operation\.moreActionsFor/, + }), ).not.toBeInTheDocument() }) }) @@ -402,21 +411,16 @@ describe('App Card Operations Flow', () => { it('should show switch option for chat mode apps', async () => { renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT }) - openOperationsMenu() - - await waitFor(() => { - expect(screen.queryByText('app.switch')).toBeInTheDocument() - }) + await openOperationsMenu() + expect(await screen.findByRole('menuitem', { name: 'app.switch' })).toBeVisible() }) it('should not show switch option for workflow apps', async () => { renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) - openOperationsMenu() - - await waitFor(() => { - expect(screen.queryByText('app.switch')).not.toBeInTheDocument() - }) + await openOperationsMenu('WF App') + expect(await screen.findByRole('menu')).toBeVisible() + expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index a6ecc0d6829..f3b2f79ed08 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' import type { AppDetailResponse } from '@/models/app' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' @@ -181,6 +181,20 @@ describe('AppCard', () => { ) }) + it('should expose the web app controls as a named region', () => { + render() + + const webAppCard = screen.getByRole('region', { + name: /(?:^|\.)overview\.appInfo\.title(?=$|:)/, + }) + + expect( + within(webAppCard).getByRole('switch', { + name: /(?:^|\.)overview\.appInfo\.title(?=$|:)/, + }), + ).toBeInTheDocument() + }) + it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { mockWorkflow = { graph: { diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index b96a15ae417..f7f6703f7a3 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -292,6 +292,8 @@ function AppCard({ return (
) : ( { expect(screen.queryByRole('link', { name: 'Preview Only App' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument() expect( - screen.queryByRole('button', { name: 'common.operation.more' }), + screen.queryByRole('button', { + name: /common\.operation\.moreActionsFor/, + }), ).not.toBeInTheDocument() fireEvent.click(tagSelector) @@ -617,7 +619,9 @@ describe('AppCard', () => { ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument() expect( - screen.queryByRole('button', { name: 'common.operation.more' }), + screen.queryByRole('button', { + name: /common\.operation\.moreActionsFor/, + }), ).not.toBeInTheDocument() fireEvent.click(card) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index c4bd32479d4..61df5be1490 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -643,7 +643,10 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) { onOpenChange={setIsOperationsMenuOpen} > $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.moreActionsFor'], { + ns: 'common', + name: app.name, + })} className={cn( 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', @@ -1271,7 +1274,10 @@ export function AppCard({ onOpenChange={setIsOperationsMenuOpen} > $['operation.more'], { ns: 'common' })} + aria-label={t(($) => $['operation.moreActionsFor'], { + ns: 'common', + name: app.name, + })} className={cn( 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover', diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index 0d67357e949..cf38adc01b3 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -3,6 +3,7 @@ import type { AppContextStateMockState } from '@/__tests__/utils/mock-app-contex import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderToString } from 'react-dom/server' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' @@ -255,6 +256,13 @@ describe('AccountDropdown', () => { expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument() }) + it('should keep the account trigger disabled in server-rendered markup', () => { + const container = document.createElement('div') + container.innerHTML = renderToString() + + expect(container.querySelector('button[aria-label="common.account.account"]')).toBeDisabled() + }) + it('should show EDU badge for education accounts', () => { // Arrange vi.mocked(useProviderContext).mockReturnValue({ diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 635b95d5055..d9c81a13b04 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useAtomValue } from 'jotai' -import { useState } from 'react' +import { useState, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' import { @@ -33,10 +33,19 @@ type AccountDropdownProps = { const mainNavMenuPopupClassName = 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]' +const subscribeHydrationState = () => () => {} +const getHydrationSnapshot = () => false +const getServerHydrationSnapshot = () => true + export default function AppSelector({ trigger, variant = 'default' }: AccountDropdownProps = {}) { const router = useRouter() const [aboutVisible, setAboutVisible] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) + const isHydrating = useSyncExternalStore( + subscribeHydrationState, + getHydrationSnapshot, + getServerHydrationSnapshot, + ) const { t } = useTranslation() const userProfile = useAtomValue(userProfileAtom) const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) @@ -64,6 +73,7 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro {trigger ? ( $['account.account'], { ns: 'common' }), @@ -71,9 +81,10 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro /> ) : ( $['account.account'], { ns: 'common' })} className={cn( - 'inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', + 'inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge disabled:cursor-default disabled:hover:bg-transparent', isAccountMenuOpen && 'bg-background-default-dodge', )} > diff --git a/web/app/components/main-nav/components/account-section.tsx b/web/app/components/main-nav/components/account-section.tsx index 3dac4faacdb..f17cfd05842 100644 --- a/web/app/components/main-nav/components/account-section.tsx +++ b/web/app/components/main-nav/components/account-section.tsx @@ -22,7 +22,7 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => { aria-label={ariaLabel} title={userProfile.name} className={cn( - 'flex min-w-0 shrink items-center rounded-full text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden', + 'flex min-w-0 shrink items-center rounded-full text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default disabled:hover:bg-transparent', compact ? 'justify-center p-1' : 'max-w-[180px] gap-3 py-1 pr-4 pl-1', isOpen && 'bg-state-base-hover', )} diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx index 08a9478412a..899ed6f4d6f 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -8,12 +8,13 @@ import { AuthCategory, CredentialTypeEnum } from '../types' // ==================== Mock Setup ==================== const mockGetPluginCredentialInfo = vi.fn() +const mockIsPluginCredentialInfoLoading = vi.fn() const mockGetPluginOAuthClientSchema = vi.fn() vi.mock('@/service/use-plugins-auth', () => ({ useGetPluginCredentialInfo: (url: string) => ({ data: url ? mockGetPluginCredentialInfo() : undefined, - isLoading: false, + isLoading: mockIsPluginCredentialInfoLoading(), }), useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }), useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }), @@ -85,6 +86,7 @@ describe('AuthorizedInNode Component', () => { beforeEach(() => { vi.clearAllMocks() mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockIsPluginCredentialInfoLoading.mockReturnValue(false) mockGetPluginCredentialInfo.mockReturnValue({ credentials: [createCredential({ is_default: true })], supported_credential_types: [CredentialTypeEnum.API_KEY], @@ -163,6 +165,25 @@ describe('AuthorizedInNode Component', () => { expect(screen.getByText('plugin.auth.authRemoved'))!.toBeInTheDocument() }) + it('should not show auth removed while credential info is loading', async () => { + const AuthorizedInNode = (await import('../authorized-in-node')).default + mockIsPluginCredentialInfoLoading.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue(undefined) + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('plugin.auth.authRemoved')).not.toBeInTheDocument() + expect(screen.getByText('common.loading')).toBeInTheDocument() + }) + it('should show unavailable when credential is not allowed', async () => { const AuthorizedInNode = (await import('../authorized-in-node')).default const credential = createCredential({ diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx index ad4117c7e6e..1d996ce4b07 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -26,6 +26,7 @@ const AuthorizedInNode = ({ canApiKey, canOAuth, credentials, + isLoading, invalidPluginCredentialInfo, notAllowCustomCredential, } = usePluginAuth(pluginPayload, true, credentialId ? [credentialId] : undefined) @@ -53,8 +54,14 @@ const AuthorizedInNode = ({ } } else { const credential = credentials.find((c) => c.id === credentialId) - label = credential ? credential.name : t(($) => $['auth.authRemoved'], { ns: 'plugin' }) - removed = !credential + if (credential) label = credential.name + else if (isLoading) { + label = t(($) => $.loading, { ns: 'common' }) + color = 'disabled' + } else { + label = t(($) => $['auth.authRemoved'], { ns: 'plugin' }) + removed = true + } unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise if (removed) color = 'error' @@ -86,7 +93,7 @@ const AuthorizedInNode = ({ ) }, - [credentialId, credentials, t], + [credentialId, credentials, isLoading, t], ) const defaultUnavailable = credentials.find((c) => c.is_default)?.not_allowed_to_use const extraAuthorizationItems: Credential[] = [ diff --git a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts index e38fb021a3f..2ec1f52f0bd 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts @@ -10,7 +10,11 @@ export const usePluginAuth = ( enable?: boolean, includeCredentialIds?: string[], ) => { - const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable, includeCredentialIds) + const { data, isLoading } = useGetPluginCredentialInfoHook( + pluginPayload, + enable, + includeCredentialIds, + ) const isAuthorized = !!data?.credentials.length const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2) const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY) @@ -20,6 +24,7 @@ export const usePluginAuth = ( isAuthorized, canOAuth, canApiKey, + isLoading, credentials: data?.credentials || [], notAllowCustomCredential: data?.allow_custom_token === false, invalidPluginCredentialInfo, diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts index 366400d4c49..2468f528df1 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts @@ -1,8 +1,11 @@ import type { CommonNodeType, Node } from '../../types' import type { ChecklistItem } from '../use-checklist' +import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/zod.gen' +import { QueryClient } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import { createElement, Fragment } from 'react' import { CollectionType } from '@/app/components/tools/types' +import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures' import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' @@ -146,6 +149,10 @@ function setupNodesMap() { checkValid: () => ({ errorMessage: '' }), metaData: { isStart: false, isRequired: false }, } + mockNodesMap[BlockEnum.AgentV2] = { + checkValid: () => ({ errorMessage: '' }), + metaData: { isStart: false, isRequired: false }, + } } beforeEach(() => { @@ -175,6 +182,82 @@ function buildConnectedGraph() { return { nodes, edges } } +function buildInlineAgentGraph({ + hasMissingFile, + hasMissingSkill, +}: { + hasMissingFile: boolean + hasMissingSkill: boolean +}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity }, + }, + }) + const appId = 'app-id' + const nodeId = 'inline-agent-node' + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: appId, + node_id: nodeId, + }, + }, + }), + zWorkflowAgentComposerResponse.parse({ + agent_soul: { + config_files: [ + { file_kind: 'upload_file', name: 'available.pdf' }, + ...(hasMissingFile + ? [{ file_kind: 'upload_file', is_missing: true, name: 'missing.pdf' }] + : []), + ], + config_skills: [ + { name: 'Available Skill' }, + ...(hasMissingSkill ? [{ is_missing: true, name: 'Missing Skill' }] : []), + ], + }, + node_job: {}, + save_options: [], + soul_lock: { locked: false }, + variant: 'workflow', + }), + ) + + const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } }) + const agentNode = createNode({ + id: nodeId, + data: { + type: BlockEnum.AgentV2, + title: 'Inline Agent', + agent_node_kind: 'dify_agent', + version: '2', + agent_binding: { + binding_type: 'inline_agent', + agent_id: 'inline-agent-id', + current_snapshot_id: 'snapshot-id', + }, + }, + }) + + return { + edges: [createEdge({ source: 'start', target: nodeId })], + nodeId, + nodes: [startNode, agentNode], + options: { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: appId, + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }, + } +} + // --------------------------------------------------------------------------- // useChecklist // --------------------------------------------------------------------------- @@ -222,6 +305,43 @@ describe('useChecklist', () => { expect(warning!.errorMessages).toContain('Model not configured') }) + it.each([ + { + errorMessage: 'agentV2.agentDetail.configure.files.missing', + hasMissingFile: true, + hasMissingSkill: false, + referenceType: 'file', + }, + { + errorMessage: 'agentV2.agentDetail.configure.skills.missing', + hasMissingFile: false, + hasMissingSkill: true, + referenceType: 'skill', + }, + ])('should report a missing $referenceType reference from inline agents', async (scenario) => { + const { edges, nodeId, nodes, options } = buildInlineAgentGraph(scenario) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options) + + await waitFor(() => { + expect(result.current).toEqual([ + expect.objectContaining({ + id: nodeId, + errorMessages: [scenario.errorMessage], + }), + ]) + }) + }) + + it('should not report available file and skill references from inline agents', () => { + const { edges, nodes, options } = buildInlineAgentGraph({ + hasMissingFile: false, + hasMissingSkill: false, + }) + const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options) + + expect(result.current).toEqual([]) + }) + it('should pass flow type to node validators', () => { const checkValid = vi.fn(() => ({ errorMessage: '' })) mockNodesMap[BlockEnum.LLM] = { diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 1176b344297..c1f4587467e 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -15,7 +15,6 @@ import type { import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Emoji } from '@/app/components/tools/types' import type { DataSet } from '@/models/datasets' -import type { FlowType } from '@/types/common' import type { I18nKeysWithPrefix } from '@/types/i18n' import { toast } from '@langgenius/dify-ui/toast' import { useQueries, useQueryClient } from '@tanstack/react-query' @@ -42,12 +41,13 @@ import { } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { AppModeEnum } from '@/types/app' +import { FlowType } from '@/types/common' import { CUSTOM_NODE } from '../constants' import { useDatasetsDetailStore } from '../datasets-detail-store/store' import { useGetToolIcon, useNodesMetaData } from '../hooks' import { useHooksStore } from '../hooks-store/store' import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils' -import { isAgentV2NodeData } from '../nodes/agent-v2/types' +import { hasValidInlineAgentBinding, isAgentV2NodeData } from '../nodes/agent-v2/types' import { IndexMethodEnum } from '../nodes/knowledge-base/types' import { getLLMModelIssue, @@ -152,8 +152,71 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT const modelProviders = useProviderContextSelector((s) => s.modelProviders) const workflowStore = useWorkflowStore() + const configsMap = useHooksStore((s) => s.configsMap) const map = useNodesAvailableVarList(nodes) + const inlineAgentNodes = useMemo( + () => + nodes.filter( + (node) => + node.type === CUSTOM_NODE && + isAgentV2NodeData(node.data) && + hasValidInlineAgentBinding(node.data), + ), + [nodes], + ) + const inlineAgentMissingReferences = useQueries({ + queries: + !configsMap?.flowId || + (configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet) + ? [] + : inlineAgentNodes.map((node) => + configsMap.flowType === FlowType.snippet + ? consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions( + { + input: { + params: { + snippet_id: configsMap.flowId, + node_id: node.id, + }, + }, + }, + ) + : consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions( + { + input: { + params: { + app_id: configsMap.flowId, + node_id: node.id, + }, + }, + }, + ), + ), + combine: (results) => { + const missingReferences: Record< + string, + { hasMissingFiles: boolean; hasMissingSkills: boolean } + > = {} + + results.forEach((result, index) => { + const nodeId = inlineAgentNodes[index]?.id + const agentSoul = result.data?.agent_soul + if (!nodeId || !agentSoul) return + + const hasMissingFiles = agentSoul.config_files?.some((file) => file.is_missing === true) + const hasMissingSkills = agentSoul.config_skills?.some((skill) => skill.is_missing === true) + if (!hasMissingFiles && !hasMissingSkills) return + + missingReferences[nodeId] = { + hasMissingFiles: !!hasMissingFiles, + hasMissingSkills: !!hasMissingSkills, + } + }) + + return missingReferences + }, + }) const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) const knowledgeBaseEmbeddingProviders = useMemo(() => { @@ -315,6 +378,16 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? if (validationError) errorMessages.push(validationError) } + const missingReferences = inlineAgentMissingReferences[node!.id] + if (missingReferences?.hasMissingFiles) + errorMessages.push( + t(($) => $['agentDetail.configure.files.missing'], { ns: 'agentV2' }), + ) + if (missingReferences?.hasMissingSkills) + errorMessages.push( + t(($) => $['agentDetail.configure.skills.missing'], { ns: 'agentV2' }), + ) + const availableVars = map[node!.id]!.availableVars let hasInvalidVar = false for (const variable of usedVars) { @@ -423,6 +496,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType? t, map, modelProviders, + inlineAgentMissingReferences, options?.flowType, ]) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index 6988574d113..95f5cb78bea 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -86,6 +86,7 @@ const mockAppComposerQueryOptions = vi.hoisted(() => } } }) => number | false + retry?: number }) => { const { input } = options @@ -96,18 +97,37 @@ const mockAppComposerQueryOptions = vi.hoisted(() => : ['workflow-agent-composer', input.params.app_id, input.params.node_id], queryFn: typeof input === 'symbol' ? input : mockAppComposerQueryFn, refetchInterval: options.refetchInterval, + retry: options.retry, } }, ), ) const mockSnippetComposerQueryOptions = vi.hoisted(() => - vi.fn(({ input }: { input: symbol | { params: { snippet_id: string; node_id: string } } }) => ({ - queryKey: - typeof input === 'symbol' - ? ['snippet-agent-composer-disabled'] - : ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], - queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn, - })), + vi.fn( + (options: { + input: symbol | { params: { snippet_id: string; node_id: string } } + refetchInterval?: (query: { + state: { + data?: { + agent?: unknown + } + } + }) => number | false + retry?: number + }) => { + const { input } = options + + return { + queryKey: + typeof input === 'symbol' + ? ['snippet-agent-composer-disabled'] + : ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], + queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn, + refetchInterval: options.refetchInterval, + retry: options.retry, + } + }, + ), ) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -187,6 +207,10 @@ vi.mock('@/service/client', () => ({ describe('useWorkflowInlineAgentDetail', () => { beforeEach(() => { vi.clearAllMocks() + mockAppComposerQueryFn.mockReset() + mockAppComposerQueryFn.mockResolvedValue({ agent: { id: 'app-agent' } }) + mockSnippetComposerQueryFn.mockReset() + mockSnippetComposerQueryFn.mockResolvedValue({ agent: { id: 'snippet-agent' } }) }) it('loads inline agent detail through the snippet composer API', async () => { @@ -255,6 +279,41 @@ describe('useWorkflowInlineAgentDetail', () => { }) expect(result.current.data).toEqual({ agent: { id: 'app-agent' } }) }) + + it('retries five times and stops polling when loading the copied inline agent composer fails', async () => { + mockAppComposerQueryFn.mockRejectedValue(new Error('Agent composer was not created')) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + retryDelay: 0, + }, + }, + }) + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentDetail('copied-node', 'source-inline-agent', { + pollUntilReady: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }, + ) + + await waitFor(() => expect(result.current.isError).toBe(true)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1200)) + }) + + expect(mockAppComposerQueryFn).toHaveBeenCalledTimes(6) + }) }) describe('useCreateInlineAgentBinding', () => { diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 5829a6e34af..ffd30bcc469 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -1023,6 +1023,37 @@ describe('agent/panel', () => { ).not.toBeInTheDocument() }) + it('shows a retry action when loading the inline agent composer fails', () => { + mockUseWorkflowInlineAgentDetail.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + refetch: mockWorkflowInlineAgentDetailRefetch, + }) + + const { container } = render( + , + ) + + expect(container.querySelector('[aria-busy="true"]')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent( + 'agentV2.roster.nodeSelector.createInlineFailed', + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalledTimes(1) + }) + it('recovers the inline setup panel open state from the node open marker', () => { mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx index 69d28cafd89..7fbdc9d97d7 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx @@ -329,11 +329,13 @@ export function AgentRosterField({ agent, agentId, canOpenPanel = true, + errorMessage, isPanelOpen, isPanelCopyPending = false, isPending = false, isLoading = false, isInlineSetup = false, + isRetrying = false, panelBody, panelMode = 'detail', showPanelDetailActions = true, @@ -341,16 +343,19 @@ export function AgentRosterField({ onChange, onMakeCopy, onPanelOpenChange, + onRetry, onSaveInlineToRoster, onStartFromScratch, }: { agent?: AgentRosterDisplayData agentId?: string canOpenPanel?: boolean + errorMessage?: string isPanelOpen?: boolean isPanelCopyPending?: boolean isLoading?: boolean isInlineSetup?: boolean + isRetrying?: boolean isPending?: boolean panelBody?: ReactNode panelMode?: AgentRosterDrawerMode @@ -359,6 +364,7 @@ export function AgentRosterField({ onChange: (agent: AgentRosterNodeData) => void onMakeCopy?: () => void onPanelOpenChange?: (open: boolean) => void + onRetry?: () => void onSaveInlineToRoster?: () => void onStartFromScratch?: () => void }) { @@ -464,7 +470,30 @@ export function AgentRosterField({
- {agent ? ( + {errorMessage ? ( +
+ + + + + {errorMessage} + + {onRetry && ( + + )} +
+ ) : agent ? ( canOpenPanel ? ( <> {isInlineSetup ? ( diff --git a/web/app/components/workflow/nodes/agent-v2/hooks.ts b/web/app/components/workflow/nodes/agent-v2/hooks.ts index 605a77f6cb5..8a739f8c82d 100644 --- a/web/app/components/workflow/nodes/agent-v2/hooks.ts +++ b/web/app/components/workflow/nodes/agent-v2/hooks.ts @@ -21,6 +21,7 @@ type CreateInlineAgentBindingOptions = { } const INLINE_AGENT_CREATION_REFETCH_INTERVAL = 1000 +const INLINE_AGENT_CREATION_RETRY_COUNT = 5 export function useAgentRosterDetail(agentId?: string) { return useQuery( @@ -46,13 +47,18 @@ export function useWorkflowInlineAgentDetail( const configsMap = useHooksStore((state) => state.configsMap) const refetchUntilReady = options?.pollUntilReady ? { + retry: INLINE_AGENT_CREATION_RETRY_COUNT, refetchInterval: (query: { state: { data?: { agent?: unknown } + status: 'error' | 'pending' | 'success' } - }) => (query.state.data?.agent ? false : INLINE_AGENT_CREATION_REFETCH_INTERVAL), + }) => + query.state.status === 'error' || query.state.data?.agent + ? false + : INLINE_AGENT_CREATION_REFETCH_INTERVAL, } : {} diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index 6941b1125ba..d8984fb9f8d 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -159,7 +159,9 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { ? inlineAgentBinding.agent_id : sourceInlineAgentId const isInlineAgentCreated = isInlineAgentReady && !!inlineAgent - const isInlineAgentWaitingForCreation = isInlineAgentReady && !isInlineAgentCreated + const isInlineAgentLoadError = isInlineAgentReady && inlineAgentQuery.isError + const isInlineAgentWaitingForCreation = + isInlineAgentReady && !isInlineAgentCreated && !isInlineAgentLoadError const isInlineAgentPanelOpen = (isInlineAgentCreated || isInlineAgentPending) && openInlineAgentPanelNodeId === id const { isPending: isAppCopyingFromRoster, mutate: copyFromRosterApp } = useMutation( @@ -619,14 +621,22 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { />
$['roster.nodeSelector.createInlineFailed'], { ns: 'agentV2' }) + : undefined + } isInlineSetup={isInlineAgentReady || isInlineAgentPending} isLoading={isInlineAgentLoading} isPanelCopyPending={isCopyingFromRoster} isPanelOpen={isAgentPanelOpen} isPending={isAgentBindingPending} + isRetrying={isInlineAgentLoadError && inlineAgentQuery.isFetching} panelBody={ isAgentPanelOpen && displayedAgent ? ( isInlineAgentReady || isInlineAgentPending ? ( @@ -662,6 +672,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { onChange={handleRosterChange} onMakeCopy={rosterAgentId ? handleMakeRosterCopy : undefined} onPanelOpenChange={handleAgentPanelOpenChange} + onRetry={isInlineAgentLoadError ? () => void inlineAgentQuery.refetch() : undefined} onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined} onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined} /> diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx index 677faf162e3..a7ee5f55ebc 100644 --- a/web/app/components/workflow/run/__tests__/status.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -96,7 +96,7 @@ describe('Status', () => { it('renders succeeded metadata values', () => { render() - expect(screen.getByText('SUCCESS')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('SUCCESS') expect(screen.getByText('1.234s')).toBeInTheDocument() expect(screen.getByText('8 Tokens')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/run/status-container.tsx b/web/app/components/workflow/run/status-container.tsx index 5e3d40eefdc..4ef510d9d2b 100644 --- a/web/app/components/workflow/run/status-container.tsx +++ b/web/app/components/workflow/run/status-container.tsx @@ -14,6 +14,7 @@ const StatusContainer: FC = ({ status, children }) => { return (
{ ]) }) + it('should preserve missing file and skill references without file ids in autosave config', () => { + const baseConfig = { + config_files: [ + { + file_id: '', + file_kind: 'upload_file', + is_missing: true, + name: 'missing.pdf', + }, + ], + config_skills: [ + { + file_id: '', + file_kind: 'tool_file', + is_missing: true, + name: 'Missing Skill', + }, + ], + } satisfies AgentSoulConfig + const formState = agentSoulConfigToFormState(baseConfig) + + const autosaveConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(autosaveConfig.config_files).toEqual([ + expect.objectContaining({ + file_id: '', + is_missing: true, + name: 'missing.pdf', + }), + ]) + expect(autosaveConfig.config_skills).toEqual([ + expect.objectContaining({ + file_id: '', + is_missing: true, + name: 'Missing Skill', + }), + ]) + }) + it('rebases draft baselines through the composer state action', () => { const store = createStore() const nextDraft = { diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index d7bf769579d..da3a3f02994 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -452,7 +452,7 @@ const toConfigSkillConfigs = ( return skills.flatMap((skill) => { const existing = existingByName.get(skill.name) const fileId = skill.fileId ?? existing?.file_id - if (!fileId) return [] + if (!fileId && !skill.isMissing) return [] return [ { @@ -463,6 +463,7 @@ const toConfigSkillConfigs = ( size: skill.size ?? existing?.size, hash: skill.hash ?? existing?.hash, mime_type: skill.mimeType ?? existing?.mime_type, + ...(skill.isMissing ? { is_missing: true } : {}), }, ] }) @@ -480,7 +481,7 @@ const toConfigFileConfigs = ( const configName = file.configName ?? file.name const existing = existingByName.get(configName) const fileId = file.fileId ?? existing?.file_id - if (!fileId) return [] + if (!fileId && !file.isMissing) return [] return [ { @@ -490,6 +491,7 @@ const toConfigFileConfigs = ( size: file.size ?? existing?.size, hash: file.hash ?? existing?.hash, mime_type: file.mimeType ?? existing?.mime_type, + ...(file.isMissing ? { is_missing: true } : {}), }, ] }) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts index 296fd60ad7f..25372c6224d 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts @@ -176,6 +176,10 @@ describe('isAgentSuggestedModel', () => { expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5-pro'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-sol'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-terra'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-luna'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('Claude Opus 4.8'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('opus-4.7'))).toBe(true) expect(isAgentSuggestedModel(provider, createModelItem('Claude Sonnet 4.6'))).toBe(true) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx index 23258fd2765..eb67e95dc7a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/env.tsx @@ -130,12 +130,15 @@ function EnvEditorScope({ function EnvEditorCell({ children, className, + role, }: { children?: React.ReactNode className?: string + role?: React.AriaRole }) { return (
- + {editable ? ( $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])} @@ -236,7 +239,7 @@ function EnvEditorRow({ )} - + {editable && !shouldMaskValue ? ( $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])} @@ -275,11 +278,11 @@ function EnvEditorRow({ )} {showScope && ( - + )} - + {editable && ( - )} - /> - ) - } - - return ( - <> - - - + ), + [t], + ) + + return ( + ) } diff --git a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts index 373dd760b2a..f172f0ae772 100644 --- a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts +++ b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts @@ -64,6 +64,7 @@ const agentSuggestedModelPatterns: RegExp[] = [ // openai /^gpt[ .-]5\.5$/i, /^gpt[ .-]5\.5[ .-]pro$/i, + /^gpt[ .-]5\.6(?:[ .-](?:sol|terra|luna))?$/i, // anthropic /^(?:claude[ .-])?opus[ .-]4\.8$/i, diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index f5f6a7fe237..2491fa0740e 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -129,6 +129,12 @@ describe('AgentRosterList', () => { expect(screen.queryByText('agent')).not.toBeInTheDocument() }) + it('exposes each agent card with the agent name', () => { + renderList([createAgent()]) + + expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument() + }) + it('uses the Figma-aligned card title and role typography', () => { renderList([createAgent()]) diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 7915bb98b0f..0da6aada8b9 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -161,7 +161,10 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { } return ( -
+
vi.fn()) +const mockImportDSL = vi.hoisted(() => vi.fn()) +const mockImportDSLConfirm = vi.hoisted(() => vi.fn()) +const mockHandleCheckPluginDependencies = vi.hoisted(() => vi.fn()) +const mockInvalidateAppList = vi.hoisted(() => vi.fn()) +const mockSetNeedRefresh = vi.hoisted(() => vi.fn()) +const mockGetRedirection = vi.hoisted(() => vi.fn()) +const mockResolveImportedAppRedirectionTarget = vi.hoisted(() => vi.fn()) +const mockAtoms = vi.hoisted(() => ({ + userProfileId: {}, + workspacePermissionKeys: {}, +})) +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: toastMocks, +})) + +vi.mock('@tanstack/react-query', () => ({ + useSuspenseQuery: () => ({ data: { rbac_enabled: false } }), +})) + +vi.mock('jotai', () => ({ + useAtomValue: (atom: object) => { + if (atom === mockAtoms.userProfileId) return 'user-1' + if (atom === mockAtoms.workspacePermissionKeys) return ['app.create_and_management'] + }, +})) + +vi.mock('@/app/components/apps/storage', () => ({ + useSetNeedRefreshAppList: () => mockSetNeedRefresh, +})) + +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/context/account-state', () => ({ + userProfileIdAtom: mockAtoms.userProfileId, +})) + +vi.mock('@/context/permission-state', () => ({ + workspacePermissionKeysAtom: mockAtoms.workspacePermissionKeys, +})) + +vi.mock('@/features/system-features/client', () => ({ + systemFeaturesQueryOptions: () => ({}), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('@/service/apps', () => ({ + importDSL: (...args: unknown[]) => mockImportDSL(...args), + importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args), +})) + +vi.mock('@/service/use-apps', () => ({ + useInvalidateAppList: () => mockInvalidateAppList, +})) + +vi.mock('@/utils/app-redirection', () => ({ + getRedirection: (...args: unknown[]) => mockGetRedirection(...args), +})) + +vi.mock('@/utils/imported-app-redirection', () => ({ + resolveImportedAppRedirectionTarget: (...args: unknown[]) => + mockResolveImportedAppRedirectionTarget(...args), +})) + +describe('useImportDSL', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveImportedAppRedirectionTarget.mockImplementation(async (target) => target) + }) + + it('should complete a confirmed import that returns warnings', async () => { + const pendingResponse = { + id: 'import-1', + status: DSLImportStatus.PENDING, + app_mode: AppModeEnum.AGENT, + imported_dsl_version: '0.2.0', + current_dsl_version: '0.1.0', + permission_keys: [], + } + const completedResponse = { + id: 'import-1', + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + app_id: 'app-1', + app_mode: AppModeEnum.AGENT, + permission_keys: ['app.acl.view_layout'], + warnings: [ + { + code: 'agent_file_omitted', + path: 'agent.omitted_assets', + message: 'Agent file was not included.', + details: {}, + }, + ], + } + const onPending = vi.fn() + const onSuccess = vi.fn() + const onFailed = vi.fn() + mockImportDSL.mockResolvedValue(pendingResponse) + mockImportDSLConfirm.mockResolvedValue(completedResponse) + + const { result } = renderHook(() => useImportDSL()) + + await act(async () => { + await result.current.handleImportDSL( + { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'app: demo', + }, + { onPending }, + ) + }) + await act(async () => { + await result.current.handleImportDSLConfirm({ onSuccess, onFailed }) + }) + + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-1' }) + expect(onSuccess).toHaveBeenCalledWith(completedResponse) + expect(onFailed).not.toHaveBeenCalled() + expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', { + description: 'app.newApp.appCreateDSLWarning', + }) + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1') + expect(mockSetNeedRefresh).toHaveBeenCalledWith('1') + expect(mockInvalidateAppList).toHaveBeenCalledTimes(1) + expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({ + id: 'app-1', + mode: AppModeEnum.AGENT, + permission_keys: ['app.acl.view_layout'], + }) + expect(mockGetRedirection).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index a6936e55b98..1e2d659e89e 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -143,9 +143,22 @@ export const useImportDSL = () => { const { status, app_id, app_mode, permission_keys } = response if (!app_id) return - if (status === DSLImportStatus.COMPLETED) { + if ( + status === DSLImportStatus.COMPLETED || + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ) { onSuccess?.(response) - toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + const message = t( + ($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], + { ns: 'app' }, + ) + const description = + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + : undefined + + if (status === DSLImportStatus.COMPLETED) toast.success(message) + else toast.warning(message, { description }) await handleCheckPluginDependencies(app_id) setNeedRefresh('1') invalidateAppList() diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index a8cbbcd5b64..481715ba874 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -471,6 +471,7 @@ "operation.log": "سجل", "operation.more": "المزيد", "operation.moreActions": "المزيد من الإجراءات", + "operation.moreActionsFor": "المزيد من الإجراءات لـ {{name}}", "operation.no": "لا", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "لم يتم العثور على {{content}}", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 8ddfb534bc4..c3f9ba7ba2f 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -471,6 +471,7 @@ "operation.log": "Protokoll", "operation.more": "Mehr", "operation.moreActions": "Weitere Aktionen", + "operation.moreActionsFor": "Weitere Aktionen für {{name}}", "operation.no": "Nein", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Es wurden keine {{content}} gefunden", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index e57f766a170..24463b81521 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -471,6 +471,7 @@ "operation.log": "Log", "operation.more": "More", "operation.moreActions": "More actions", + "operation.moreActionsFor": "More actions for {{name}}", "operation.no": "No", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "No {{content}} were found", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 603eb952a54..8d60586957b 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -471,6 +471,7 @@ "operation.log": "Registro", "operation.more": "Más", "operation.moreActions": "Más acciones", + "operation.moreActionsFor": "Más acciones para {{name}}", "operation.no": "No", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "No se encontraron {{content}}", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 00261b0047b..9f378917c79 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -471,6 +471,7 @@ "operation.log": "گزارش", "operation.more": "بیشتر", "operation.moreActions": "اقدامات بیشتر", + "operation.moreActionsFor": "اقدامات بیشتر برای {{name}}", "operation.no": "نه", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "هیچ {{content}} یافت نشد", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 68aad40b25e..b1d66c2b080 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -471,6 +471,7 @@ "operation.log": "Journal", "operation.more": "Plus", "operation.moreActions": "Plus d'actions", + "operation.moreActionsFor": "Plus d'actions pour {{name}}", "operation.no": "Non", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Aucun {{content}} n'a été trouvé", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 7f2f662bdd1..1c1cb77836d 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -471,6 +471,7 @@ "operation.log": "लॉग", "operation.more": "अधिक", "operation.moreActions": "अधिक कार्रवाइयाँ", + "operation.moreActionsFor": "{{name}} के लिए अधिक कार्रवाइयाँ", "operation.no": "नहीं", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "कोई {{content}} नहीं मिला", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 6e299a0fe0a..48e95cdc16f 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -471,6 +471,7 @@ "operation.log": "Batang", "operation.more": "Lebih", "operation.moreActions": "Tindakan lainnya", + "operation.moreActionsFor": "Tindakan lainnya untuk {{name}}", "operation.no": "Tidak", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Tidak ada {{content}} yang ditemukan", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 1148ef94d3d..f19d52870a0 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -471,6 +471,7 @@ "operation.log": "Log", "operation.more": "Di più", "operation.moreActions": "Altre azioni", + "operation.moreActionsFor": "Altre azioni per {{name}}", "operation.no": "No", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Nessun {{content}} è stato trovato", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index 26989e72891..8a2d7741e7d 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -471,6 +471,7 @@ "operation.log": "ログ", "operation.more": "もっと", "operation.moreActions": "その他の操作", + "operation.moreActionsFor": "{{name}} のその他の操作", "operation.no": "いいえ", "operation.noSearchCount": "0件の{{content}}", "operation.noSearchResults": "{{content}}は見つかりませんでした", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 14d7987400f..eb6ff0cdf78 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -471,6 +471,7 @@ "operation.log": "로그", "operation.more": "더 많은", "operation.moreActions": "추가 작업", + "operation.moreActionsFor": "{{name}}에 대한 추가 작업", "operation.no": "아니요", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "{{content}}가(이) 발견되지 않았습니다", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index fed5a7b15de..a2e640c97ab 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -471,6 +471,7 @@ "operation.log": "Log", "operation.more": "More", "operation.moreActions": "Meer acties", + "operation.moreActionsFor": "Meer acties voor {{name}}", "operation.no": "No", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "No {{content}} were found", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index ea984c6343b..9f2643167b4 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -471,6 +471,7 @@ "operation.log": "Dziennik", "operation.more": "Więcej", "operation.moreActions": "Więcej akcji", + "operation.moreActionsFor": "Więcej akcji dla {{name}}", "operation.no": "Nie", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Nie znaleziono {{content}}", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index d92db5c422e..ee48ef3cc86 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -471,6 +471,7 @@ "operation.log": "Log", "operation.more": "Mais", "operation.moreActions": "Mais ações", + "operation.moreActionsFor": "Mais ações para {{name}}", "operation.no": "Não", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Nenhum {{content}} foi encontrado", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 3fbbe4de6a1..70af079fa55 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -471,6 +471,7 @@ "operation.log": "Jurnal", "operation.more": "Mai mult", "operation.moreActions": "Mai multe acțiuni", + "operation.moreActionsFor": "Mai multe acțiuni pentru {{name}}", "operation.no": "Nu", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Nu au fost găsite {{content}}", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index d1c8a556a5d..085d4d4f6b4 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -471,6 +471,7 @@ "operation.log": "Журнал", "operation.more": "Больше", "operation.moreActions": "Дополнительные действия", + "operation.moreActionsFor": "Дополнительные действия для {{name}}", "operation.no": "Нет", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Ничего {{content}} не найдено", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 0bfd7f9adb5..175e45c9077 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -471,6 +471,7 @@ "operation.log": "Dnevnik", "operation.more": "Več", "operation.moreActions": "Več dejanj", + "operation.moreActionsFor": "Več dejanj za {{name}}", "operation.no": "Ne", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Ni bilo najdenih {{content}}", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index b545c8ceb34..278c713cc39 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -471,6 +471,7 @@ "operation.log": "ซุง", "operation.more": "มากขึ้น", "operation.moreActions": "การดำเนินการเพิ่มเติม", + "operation.moreActionsFor": "การดำเนินการเพิ่มเติมสำหรับ {{name}}", "operation.no": "ไม่", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "ไม่พบ {{content}}", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index b0d1a511714..26e683afaa2 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -471,6 +471,7 @@ "operation.log": "log", "operation.more": "Daha fazla", "operation.moreActions": "Daha fazla işlem", + "operation.moreActionsFor": "{{name}} için daha fazla işlem", "operation.no": "Hayır", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Hiç {{content}} bulunamadı", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index 08882bd26fe..3e880810475 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -471,6 +471,7 @@ "operation.log": "Журнал", "operation.more": "Більше", "operation.moreActions": "Більше дій", + "operation.moreActionsFor": "Більше дій для {{name}}", "operation.no": "Ні", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Жодного {{content}} не знайдено", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index fbc539b07ad..5b694eb8236 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -471,6 +471,7 @@ "operation.log": "Nhật ký", "operation.more": "Hơn", "operation.moreActions": "Thêm hành động", + "operation.moreActionsFor": "Thêm hành động cho {{name}}", "operation.no": "Không", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "Không tìm thấy {{content}} nào", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 708cbe58db2..51457bcd447 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -471,6 +471,7 @@ "operation.log": "日志", "operation.more": "更多", "operation.moreActions": "更多操作", + "operation.moreActionsFor": "{{name}} 的更多操作", "operation.no": "否", "operation.noSearchCount": "0 个 {{content}}", "operation.noSearchResults": "没有找到{{content}}", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index c808390e1a5..b4725c627c5 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -471,6 +471,7 @@ "operation.log": "日誌", "operation.more": "更多", "operation.moreActions": "更多操作", + "operation.moreActionsFor": "{{name}} 的更多操作", "operation.no": "不", "operation.noSearchCount": "0 {{content}}", "operation.noSearchResults": "未找到 {{content}}", diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index e2c0f117b03..ffa6393a8e9 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -184,6 +184,59 @@ describe('handleStream', () => { expect(onCompleted).toHaveBeenCalledWith(true, 'Bad request') }) + it.each([ + { + name: 'an error event', + payload: { + event: 'error', + message: 'Stream failed', + code: 'stream_failed', + }, + }, + { + name: 'a numeric error status', + payload: { + event: 'message', + status: 500, + message: 'Internal server error', + code: 'internal_server_error', + }, + }, + ])('should handle $name through the error callbacks', async ({ payload }) => { + const onData = vi.fn() + const onCompleted = vi.fn() + const mockReader = { + read: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n`), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + handleStream(mockResponse, onData, onCompleted) + + await waitFor(() => { + expect(onData).toHaveBeenCalledWith('', false, { + conversationId: undefined, + messageId: '', + errorMessage: payload.message, + errorCode: payload.code, + }) + }) + expect(onCompleted).toHaveBeenCalledWith(true, payload.message) + }) + it('should handle malformed JSON gracefully', async () => { const onData = vi.fn() const onCompleted = vi.fn() diff --git a/web/service/base.ts b/web/service/base.ts index 2e244ef20c1..ee07341da61 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -328,7 +328,8 @@ export const handleStream = ( onCompleted?.(true, 'Invalid response data') return } - if (bufferObj.status === 400 || !bufferObj.event) { + const hasErrorStatus = typeof bufferObj.status === 'number' && bufferObj.status >= 400 + if (bufferObj.event === 'error' || hasErrorStatus || !bufferObj.event) { onData('', false, { conversationId: undefined, messageId: '',