Compare commits

..
Author SHA1 Message Date
Yunlu WenandGitHub 5c6372d2f7 chore: bump version to 1.16.0 (#39196) 2026-07-17 09:28:32 +00:00
yyhandGitHub 5fd06fafe0 test(web): remove low-value unit tests (#39193) 2026-07-17 08:59:26 +00:00
zyssyz123andGitHub af4b65b295 fix(agent): allow dangling refs in workflow drafts (#39192) 2026-07-17 08:42:58 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6e5fc1081b feat: refact agent shell output (#39190)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-17 08:27:00 +00:00
JoelandGitHub 0e84ae7338 fix: can not typing in the change node search field (#39189) 2026-07-17 08:01:46 +00:00
zyssyz123andGitHub 5a792945f5 fix(agent): bound streams and cancel zombie workflow runs (#39186) 2026-07-17 07:46:42 +00:00
Wu TianweiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2f3c785f27 feat: add Responses API tip for GPT-5.6 and later models in multiple languages (#39188)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-17 07:34:38 +00:00
JoelandGitHub 38045078dc fix: can not create app from learn dify in app list page (#39187) 2026-07-17 07:20:31 +00:00
sahasweetyGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
83f40b85f4 fix: remove patch logger in test, use caplog instead (Closes #37468) (#39159)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-17 06:22:20 +00:00
非法操作andGitHub aff8a49bbf fix: change email not work (#39182) 2026-07-17 06:05:20 +00:00
JoelandGitHub b0329cd53c chore: add agent tooltip (#39183) 2026-07-17 06:00:29 +00:00
yyhandGitHub 2a008423d8 chore: upgrade Vite+ to 0.2.5 (#39181) 2026-07-17 05:39:02 +00:00
yyhandGitHub f962c9e47a fix(web): settle running nodes after workflow failure (#39173) 2026-07-17 05:15:01 +00:00
yyhandGitHub af7e59de7c fix(web): show current profile in main navigation (#39172) 2026-07-17 04:11:35 +00:00
1131 changed files with 6250 additions and 101398 deletions
+3
View File
@@ -677,6 +677,9 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
# Dify Agent backend
AGENT_BACKEND_BASE_URL=http://localhost:5050
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
# Marketplace configuration
MARKETPLACE_ENABLED=true
+40 -6
View File
@@ -8,7 +8,7 @@ creating another wire contract.
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from typing import Protocol
from dify_agent.client import (
@@ -45,7 +45,13 @@ class AgentBackendRunClient(Protocol):
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for one Agent backend run."""
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
"""Yield public ``dify-agent`` run events in stream order."""
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
@@ -61,7 +67,15 @@ class _DifyAgentSyncClient(Protocol):
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run synchronously."""
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
def stream_events_sync(
self,
run_id: str,
*,
after: str | None = None,
max_reconnects: int | None = None,
timeout_seconds: float | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
"""Stream run events synchronously."""
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
@@ -73,8 +87,16 @@ class DifyAgentBackendRunClient:
client: _DifyAgentSyncClient
def __init__(self, client: _DifyAgentSyncClient) -> None:
def __init__(
self,
client: _DifyAgentSyncClient,
*,
stream_max_reconnects: int = 3,
stream_timeout_seconds: float = 1200,
) -> None:
self.client = client
self._stream_max_reconnects = stream_max_reconnects
self._stream_timeout_seconds = stream_timeout_seconds
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
"""Create one run through ``POST /runs`` and normalize client exceptions."""
@@ -90,10 +112,22 @@ class DifyAgentBackendRunClient:
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
"""Stream run events from ``/events/sse`` with the wrapped client's reconnect policy."""
try:
yield from self.client.stream_events_sync(run_id, after=after)
yield from self.client.stream_events_sync(
run_id,
after=after,
max_reconnects=self._stream_max_reconnects,
timeout_seconds=self._stream_timeout_seconds,
should_stop=should_stop,
)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
+8 -1
View File
@@ -13,10 +13,17 @@ def create_agent_backend_run_client(
base_url: str | None = None,
use_fake: bool = False,
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
stream_read_timeout_seconds: float = 30,
stream_max_reconnects: int = 3,
stream_run_timeout_seconds: float = 1200,
) -> AgentBackendRunClient:
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
if use_fake:
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
return DifyAgentBackendRunClient(Client(base_url=base_url))
return DifyAgentBackendRunClient(
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds),
stream_max_reconnects=stream_max_reconnects,
stream_timeout_seconds=stream_run_timeout_seconds,
)
+10 -2
View File
@@ -7,7 +7,7 @@ separate ``agent-backend.v1`` event stream.
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from enum import StrEnum
@@ -69,9 +69,17 @@ class FakeAgentBackendRunClient:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
"""Yield the deterministic public ``RunEvent`` sequence for ``run_id``."""
for event in self._events(run_id):
if should_stop is not None and should_stop():
return
if after is not None and event.id is not None and event.id <= after:
continue
yield event
+16 -1
View File
@@ -1,4 +1,4 @@
from pydantic import Field, NonNegativeFloat
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat
from pydantic_settings import BaseSettings
@@ -22,6 +22,21 @@ class AgentBackendConfig(BaseSettings):
default="success",
)
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: PositiveFloat = Field(
description="Read timeout for one Agent backend SSE connection.",
default=30,
)
AGENT_BACKEND_STREAM_MAX_RECONNECTS: NonNegativeInt = Field(
description="Maximum Agent backend SSE reconnects before failing the run.",
default=3,
)
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: PositiveFloat = Field(
description="Total deadline for one Agent backend run event stream.",
default=1200,
)
AGENT_SHELL_ENABLED: bool = Field(
description=(
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. "
@@ -62,8 +62,6 @@ class ModelConfigConverter:
if "stop" in completion_params:
stop = completion_params["stop"]
del completion_params["stop"]
# Workflow-only setting; never forward it to providers.
completion_params.pop("first_token_timeout_ms", None)
model_schema = model_type_instance.get_model_schema(model_config.model, model_credentials)
@@ -540,6 +540,9 @@ class AgentAppGenerator(MessageBasedAppGenerator):
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
stream_max_reconnects=dify_config.AGENT_BACKEND_STREAM_MAX_RECONNECTS,
stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS,
),
event_adapter=AgentBackendRunEventAdapter(),
session_store=AgentAppRuntimeSessionStore(),
+51 -35
View File
@@ -941,48 +941,64 @@ class AgentAppRunner:
if pending_text:
persist_answer_text(pending_text)
for public_event in self._agent_backend_client.stream_events(run_id):
if queue_manager.is_stopped():
flush_pending_agent_message_text()
self._cancel_run(run_id)
raise GenerateTaskStoppedError()
for internal_event in self._event_adapter.adapt(public_event):
try:
public_events = self._agent_backend_client.stream_events(
run_id,
should_stop=queue_manager.is_stopped,
)
for public_event in public_events:
if queue_manager.is_stopped():
flush_pending_agent_message_text()
self._cancel_run(run_id)
raise GenerateTaskStoppedError()
if internal_event.type in (
AgentBackendInternalEventType.RUN_STARTED,
AgentBackendInternalEventType.STREAM_EVENT,
AgentBackendInternalEventType.AGENT_MESSAGE_DELTA,
):
if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent):
debounced_delta = text_delta_debouncer.push(internal_event.delta)
if debounced_delta:
persist_answer_text(debounced_delta)
continue
if isinstance(internal_event, AgentBackendStreamInternalEvent):
for internal_event in self._event_adapter.adapt(public_event):
if queue_manager.is_stopped():
flush_pending_agent_message_text()
try:
process_recorder.handle_stream_event(internal_event)
except Exception:
db.session.rollback()
logger.warning(
"Failed to persist Agent App process event: run_id=%s message_id=%s event_kind=%s",
run_id,
message_id,
internal_event.event_kind,
exc_info=True,
)
self._cancel_run(run_id)
raise GenerateTaskStoppedError()
if internal_event.type in (
AgentBackendInternalEventType.RUN_STARTED,
AgentBackendInternalEventType.STREAM_EVENT,
AgentBackendInternalEventType.AGENT_MESSAGE_DELTA,
):
if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent):
debounced_delta = text_delta_debouncer.push(internal_event.delta)
if debounced_delta:
persist_answer_text(debounced_delta)
continue
if isinstance(internal_event, AgentBackendStreamInternalEvent):
flush_pending_agent_message_text()
try:
process_recorder.handle_stream_event(internal_event)
except Exception:
db.session.rollback()
logger.warning(
"Failed to persist Agent App process event: run_id=%s message_id=%s event_kind=%s",
run_id,
message_id,
internal_event.event_kind,
exc_info=True,
)
continue
continue
continue
flush_pending_agent_message_text()
terminal = internal_event
break
if terminal is not None:
break
flush_pending_agent_message_text()
terminal = internal_event
break
if terminal is not None:
break
except GenerateTaskStoppedError:
raise
except Exception as error:
flush_pending_agent_message_text()
self._cancel_run(run_id)
if queue_manager.is_stopped():
raise GenerateTaskStoppedError() from error
raise
flush_pending_agent_message_text()
if queue_manager.is_stopped():
self._cancel_run(run_id)
raise GenerateTaskStoppedError()
return terminal, process_recorder
def _cancel_run(self, run_id: str) -> None:
+31 -4
View File
@@ -21,6 +21,7 @@ from core.app.entities.queue_entities import (
WorkflowQueueMessage,
)
from extensions.ext_redis import redis_client
from graphon.graph_engine.manager import GraphEngineManager
from graphon.runtime import GraphRuntimeState
logger = logging.getLogger(__name__)
@@ -51,6 +52,9 @@ class AppQueueManager(ABC):
self._graph_runtime_state: GraphRuntimeState | None = None
self._stopped_cache: TTLCache[tuple, bool] = TTLCache(maxsize=1, ttl=1)
self._cache_lock = threading.Lock()
self._execution_terminal = threading.Event()
self._abort_sent = threading.Event()
self._lifecycle_lock = threading.Lock()
def listen(self):
"""
@@ -59,7 +63,7 @@ class AppQueueManager(ABC):
"""
# wait for APP_MAX_EXECUTION_TIME seconds to stop listen
listen_timeout = dify_config.APP_MAX_EXECUTION_TIME
start_time = time.time()
start_time = time.monotonic()
last_ping_time: int | float = 0
try:
while True:
@@ -72,8 +76,14 @@ class AppQueueManager(ABC):
except queue.Empty:
continue
finally:
elapsed_time = time.time() - start_time
if elapsed_time >= listen_timeout or self._is_stopped():
elapsed_time = time.monotonic() - start_time
timed_out = elapsed_time >= listen_timeout
manually_stopped = self._is_stopped()
if not self._execution_terminal.is_set() and (timed_out or manually_stopped):
reason = (
f"App execution exceeded {listen_timeout} seconds" if timed_out else "App task was stopped"
)
self._abort_execution(reason)
# publish two messages to make sure the client can receive the stop signal
# and stop listening after the stop signal processed
self.publish(
@@ -84,16 +94,33 @@ class AppQueueManager(ABC):
self.publish(QueuePingEvent(), PublishFrom.TASK_PIPELINE)
last_ping_time = elapsed_time // 10
finally:
if not self._execution_terminal.is_set():
self._abort_execution("Client response stream closed before app execution completed")
self._graph_runtime_state = None # Release reference once consumers finish or close the generator.
def stop_listen(self):
def stop_listen(self, *, execution_terminal: bool = False):
"""
Stop listen to queue
:return:
"""
if execution_terminal:
self._execution_terminal.set()
self._clear_task_belong_cache()
self._q.put(None)
def _abort_execution(self, reason: str) -> None:
"""Propagate response timeout/disconnect to legacy and GraphEngine runners."""
with self._lifecycle_lock:
if self._execution_terminal.is_set() or self._abort_sent.is_set():
return
self._abort_sent.set()
try:
self.set_stop_flag_no_user_check(self._task_id)
GraphEngineManager(redis_client).send_stop_command(self._task_id, reason=reason)
except Exception:
logger.exception("Failed to abort app execution for task %s", self._task_id)
def _clear_task_belong_cache(self) -> None:
"""
Remove the task belong cache key once listening is finished.
@@ -45,7 +45,7 @@ class MessageBasedAppQueueManager(AppQueueManager):
if isinstance(
event, QueueStopEvent | QueueErrorEvent | QueueMessageEndEvent | QueueAdvancedChatMessageEndEvent
):
self.stop_listen()
self.stop_listen(execution_terminal=True)
if pub_from == PublishFrom.APPLICATION_MANAGER and self._is_stopped():
if self._app_mode == AppMode.ADVANCED_CHAT.value:
@@ -42,7 +42,7 @@ class PipelineQueueManager(AppQueueManager):
| QueueWorkflowFailedEvent
| QueueWorkflowPartialSuccessEvent,
):
self.stop_listen()
self.stop_listen(execution_terminal=True)
if pub_from == PublishFrom.APPLICATION_MANAGER and self._is_stopped():
raise GenerateTaskStoppedError()
@@ -41,4 +41,4 @@ class WorkflowAppQueueManager(AppQueueManager):
| QueueWorkflowFailedEvent
| QueueWorkflowPartialSuccessEvent,
):
self.stop_listen()
self.stop_listen(execution_terminal=True)
+7 -1
View File
@@ -26,6 +26,7 @@ from core.app.entities.queue_entities import (
QueueNodeSucceededEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
@@ -424,7 +425,12 @@ class WorkflowBasedAppRunner:
QueueWorkflowFailedEvent(error=event.error, exceptions_count=event.exceptions_count)
)
case GraphRunAbortedEvent():
self._publish_event(QueueWorkflowFailedEvent(error=event.reason or "Unknown error", exceptions_count=0))
self._publish_event(
QueueStopEvent(
stopped_by=QueueStopEvent.StopBy.USER_MANUAL,
reason=event.reason or "Workflow execution aborted",
)
)
case GraphRunPausedEvent():
runtime_state = workflow_entry.graph_engine.graph_runtime_state
paused_nodes = runtime_state.get_paused_nodes()
+4
View File
@@ -500,11 +500,15 @@ class QueueStopEvent(AppQueueEvent):
event: QueueEvent = QueueEvent.STOP
stopped_by: StopBy
reason: str | None = None
def get_stop_reason(self) -> str:
"""
To stop reason
"""
if self.reason:
return self.reason
reason_mapping = {
QueueStopEvent.StopBy.USER_MANUAL: "Stopped by user.",
QueueStopEvent.StopBy.ANNOTATION_REPLY: "Stopped by annotation reply.",
+7 -25
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import logging
from copy import deepcopy
from typing import Any
@@ -15,8 +14,6 @@ from graphon.nodes.llm.entities import ModelConfig
from graphon.nodes.llm.exc import LLMModeRequiredError, ModelNotExistError
from graphon.nodes.llm.protocols import CredentialsProvider
logger = logging.getLogger(__name__)
class DifyCredentialsProvider:
"""Resolves and returns LLM credentials for a given provider and model.
@@ -131,35 +128,21 @@ def build_dify_model_access(run_context: DifyRunContext) -> tuple[CredentialsPro
)
def _normalize_completion_params(
completion_params: dict[str, Any],
) -> tuple[dict[str, Any], list[str], float | None]:
def _normalize_completion_params(completion_params: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""
Split node-level completion params into provider parameters, stop sequences,
and the first-token timeout.
Split node-level completion params into provider parameters and stop sequences.
Workflow LLM-compatible nodes still consume runtime invocation settings from
``ModelInstance.parameters``, ``ModelInstance.stop`` and
``ModelInstance.first_token_timeout``. Keep the ``ModelInstance`` view and the
returned config entity aligned here so callers do not need to duplicate
normalization logic.
``first_token_timeout_ms`` never reaches providers; this is the only ms->s
conversion on the path. Invalid values disable the gate.
``ModelInstance.parameters`` and ``ModelInstance.stop``. Keep the
``ModelInstance`` view and the returned config entity aligned here so callers
do not need to duplicate normalization logic.
"""
normalized_parameters = dict(completion_params)
stop = normalized_parameters.pop("stop", [])
if not isinstance(stop, list) or not all(isinstance(item, str) for item in stop):
stop = []
raw_timeout_ms = normalized_parameters.pop("first_token_timeout_ms", None)
first_token_timeout: float | None = None
if isinstance(raw_timeout_ms, (int, float)) and not isinstance(raw_timeout_ms, bool) and raw_timeout_ms > 0:
first_token_timeout = float(raw_timeout_ms) / 1000
elif raw_timeout_ms is not None:
logger.debug("Ignoring invalid first_token_timeout_ms in completion_params: %r", raw_timeout_ms)
return normalized_parameters, stop, first_token_timeout
return normalized_parameters, stop
def fetch_model_config(
@@ -195,13 +178,12 @@ def fetch_model_config(
if model_schema is None:
raise ModelNotExistError(f"Model {node_data_model.name} schema does not exist.")
parameters, stop, first_token_timeout = _normalize_completion_params(node_data_model.completion_params)
parameters, stop = _normalize_completion_params(node_data_model.completion_params)
model_instance.provider = node_data_model.provider
model_instance.model_name = node_data_model.name
model_instance.credentials = credentials
model_instance.parameters = parameters
model_instance.stop = tuple(stop)
model_instance.first_token_timeout = first_token_timeout
return model_instance, ModelConfigWithCredentialsEntity(
provider=node_data_model.provider,
-1
View File
@@ -47,7 +47,6 @@ class ModelInstance:
# Runtime LLM invocation fields.
self.parameters: Mapping[str, Any] = {}
self.stop: Sequence[str] = ()
self.first_token_timeout: float | None = None
self.model_type_instance = self.provider_model_bundle.model_type_instance
self.load_balancing_manager = self._get_load_balancing_manager(
configuration=provider_model_bundle.configuration,
+3 -39
View File
@@ -25,7 +25,6 @@ from core.plugin.impl.exc import (
PluginPermissionDeniedError,
PluginUniqueIdentifierError,
)
from core.plugin.impl.first_token_timeout import FirstTokenTimeoutError, first_token_timeout_ctx
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@@ -55,22 +54,6 @@ match _plugin_daemon_timeout_config:
case _:
plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config)
def _read_timeout_for(first_token_timeout: float | None) -> httpx.Timeout | None:
"""Replace the daemon request timeout's ``read`` component with the first-token budget.
Deliberately a replacement rather than a narrowing, so the budget may exceed
``PLUGIN_DAEMON_TIMEOUT`` for slow reasoning models. ``httpx.Timeout(base, read=x)``
rejects a ``Timeout`` base, so the other components are copied explicitly.
"""
base = plugin_daemon_request_timeout
if not first_token_timeout or first_token_timeout <= 0:
return base
if base is None:
return httpx.Timeout(None, read=first_token_timeout)
return httpx.Timeout(connect=base.connect, read=first_token_timeout, write=base.write, pool=base.pool)
logger = logging.getLogger(__name__)
PLUGIN_DAEMON_MAX_PATH_LENGTH = 4096
@@ -198,49 +181,30 @@ class BasePluginClient:
"""
url, headers, prepared_data, params, files = self._prepare_request(path, headers, data, params, files)
first_token_timeout = first_token_timeout_ctx.get()
first_token_gate = bool(first_token_timeout and first_token_timeout > 0)
stream_kwargs: dict[str, Any] = {
"method": method,
"url": url,
"headers": headers,
"params": params,
"files": files,
# The daemon sends nothing before the first token, so the read timeout gates TTFT.
"timeout": _read_timeout_for(first_token_timeout),
"timeout": plugin_daemon_request_timeout,
}
if isinstance(prepared_data, dict):
stream_kwargs["data"] = prepared_data
elif prepared_data is not None:
stream_kwargs["content"] = prepared_data
first_token_seen = False
try:
with _httpx_client.stream(**stream_kwargs) as response:
for raw_line in response.iter_lines():
# Blank frames don't count as the first token, yet each read refreshes the
# read window -- gating relies on no keep-alives before the first token.
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
line = line.strip()
if line.startswith("data:"):
line = line[5:].strip()
if not line:
continue
first_token_seen = True
yield line
except httpx.ReadTimeout as e:
if first_token_gate and not first_token_seen:
raise FirstTokenTimeoutError(f"The first token was not received within {first_token_timeout}s.") from e
logger.exception("Stream request to Plugin Daemon Service failed")
message = "Request to Plugin Daemon Service failed"
if first_token_gate:
# An inter-token stall past the read window is a plain transport error,
# but name the window so it stays traceable to the user's setting.
message += f" (stream stalled beyond the {first_token_timeout}s first-token timeout window)"
raise PluginDaemonInnerError(code=-500, message=message)
if line:
yield line
except httpx.RequestError:
logger.exception("Stream request to Plugin Daemon Service failed")
raise PluginDaemonInnerError(code=-500, message="Request to Plugin Daemon Service failed")
@@ -1,27 +0,0 @@
"""First-token timeout plumbing for LLM streaming through the plugin daemon.
Configured per model as ``completion_params.first_token_timeout_ms`` and popped into
``ModelInstance.first_token_timeout`` by ``_normalize_completion_params`` -- the only
ms->s conversion; everything below the pop point is seconds. ``DifyPreparedLLM`` sets
the ContextVar around invocation, and ``BasePluginClient._stream_request`` applies it
as the per-request httpx ``read`` timeout. The daemon sends neither response headers
nor keep-alives before the first token, so the read timeout measures
time-to-first-token directly.
The ContextVar only reaches the transport on the thread that set it; a
background-thread stream consumer would read ``None`` and the gate fails open.
"""
from contextvars import ContextVar
from graphon.model_runtime.errors.invoke import InvokeError
class FirstTokenTimeoutError(InvokeError):
"""The model did not stream its first token within the configured budget."""
description = "The first streamed token was not received in time."
# Seconds; None or non-positive disables the gate.
first_token_timeout_ctx: ContextVar[float | None] = ContextVar("first_token_timeout", default=None)
+3
View File
@@ -499,6 +499,9 @@ class DifyNodeFactory(NodeFactory):
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
stream_max_reconnects=dify_config.AGENT_BACKEND_STREAM_MAX_RECONNECTS,
stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS,
),
"event_adapter": AgentBackendRunEventAdapter(),
# Agent Files §4.6: reback file outputs from the ToolFile row so
+16 -59
View File
@@ -22,7 +22,6 @@ from core.llm_generator.output_parser.errors import OutputParserError
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.model_manager import ModelInstance
from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError
from core.plugin.impl.first_token_timeout import first_token_timeout_ctx
from core.plugin.impl.plugin import PluginInstaller
from core.prompt.utils.prompt_message_util import PromptMessageUtil
from core.repositories.human_input_repository import (
@@ -148,42 +147,6 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
)
def _guarded_stream(
seconds: float,
inner: Generator[Any, None, None],
) -> Generator[Any, None, None]:
"""Iterate ``inner`` with the first-token-timeout ContextVar set.
Keeps the value active for exactly the span of iteration, which is when the
plugin-daemon transport reads it. Same-thread only: a background-thread SSE
prefetch would not see it and the gate fails open.
"""
token = first_token_timeout_ctx.set(seconds)
try:
yield from inner
finally:
first_token_timeout_ctx.reset(token)
def _with_first_token_timeout[T](first_token_timeout: float | None, invoke: Callable[[], T]) -> T:
"""Run ``invoke`` with the first-token-timeout ContextVar applied.
``first_token_timeout`` is seconds, from ``ModelInstance.first_token_timeout``.
A streaming result is lazy, so its generator is wrapped to keep the ContextVar
set during iteration; a non-positive timeout disables the gate.
"""
if first_token_timeout is None or first_token_timeout <= 0:
return invoke()
token = first_token_timeout_ctx.set(first_token_timeout)
try:
result = invoke()
finally:
first_token_timeout_ctx.reset(token)
if isinstance(result, Generator):
return cast("T", _guarded_stream(first_token_timeout, result))
return result
class DifyPreparedLLM(LLMProtocol):
"""Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes."""
@@ -262,16 +225,13 @@ class DifyPreparedLLM(LLMProtocol):
stop: Sequence[str] | None,
stream: bool,
) -> LLMResult | Generator[LLMResultChunk, None, None]:
return _with_first_token_timeout(
self._model_instance.first_token_timeout,
lambda: self._model_instance.invoke_llm(
prompt_messages=list(prompt_messages),
model_parameters=dict(model_parameters),
tools=list(tools or []),
stop=list(stop or []),
stream=stream,
request_metadata=self._request_metadata,
),
return self._model_instance.invoke_llm(
prompt_messages=list(prompt_messages),
model_parameters=dict(model_parameters),
tools=list(tools or []),
stop=list(stop or []),
stream=stream,
request_metadata=self._request_metadata,
)
@overload
@@ -306,18 +266,15 @@ class DifyPreparedLLM(LLMProtocol):
stop: Sequence[str] | None,
stream: bool,
) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]:
return _with_first_token_timeout(
self._model_instance.first_token_timeout,
lambda: invoke_llm_with_structured_output(
provider=self.provider,
model_schema=self.get_model_schema(),
model_instance=self._model_instance,
prompt_messages=prompt_messages,
json_schema=json_schema,
model_parameters=model_parameters,
stop=list(stop or []),
stream=stream,
),
return invoke_llm_with_structured_output(
provider=self.provider,
model_schema=self.get_model_schema(),
model_instance=self._model_instance,
prompt_messages=prompt_messages,
json_schema=json_schema,
model_parameters=model_parameters,
stop=list(stop or []),
stream=stream,
)
@override
+28 -1
View File
@@ -5,6 +5,7 @@ from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, override
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol import CancelRunRequest
from clients.agent_backend import (
AgentBackendAgentMessageDeltaInternalEvent,
@@ -473,7 +474,10 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
"""
stream_event_count = 0
try:
for public_event in self._agent_backend_client.stream_events(run_id):
for public_event in self._agent_backend_client.stream_events(
run_id,
should_stop=self._is_graph_aborted,
):
stream_event_count += 1
for internal_event in self._event_adapter.adapt(public_event):
if internal_event.type == AgentBackendInternalEventType.RUN_STARTED:
@@ -501,6 +505,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
| AgentBackendDeferredToolCallInternalEvent,
):
return internal_event, None
self._cancel_backend_run(run_id, reason="unexpected_event")
return None, self._failure_event(
inputs={},
process_data={},
@@ -509,6 +514,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type="agent_backend_stream_error",
)
except AgentBackendError as error:
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
return None, self._failure_event(
inputs={},
process_data={},
@@ -517,6 +523,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type=self._agent_backend_error_type(error),
)
except Exception as error:
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
return None, self._failure_event(
inputs={},
process_data={},
@@ -525,8 +532,28 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type="agent_backend_stream_error",
)
self._cancel_backend_run(run_id, reason="stream_ended_without_terminal_event")
return None, None
def _is_graph_aborted(self) -> bool:
"""Let Agent SSE consumption observe GraphEngine's cooperative abort state."""
try:
return self.graph_runtime_state.graph_execution.aborted
except (AttributeError, RuntimeError):
return False
def _stream_stop_reason(self) -> str:
return "workflow_graph_aborted" if self._is_graph_aborted() else "event_stream_failed"
def _cancel_backend_run(self, run_id: str, *, reason: str) -> None:
try:
self._agent_backend_client.cancel_run(
run_id,
CancelRunRequest(reason=reason, message="Workflow Agent event consumption stopped"),
)
except Exception:
logger.warning("Failed to cancel Workflow Agent backend run: run_id=%s", run_id, exc_info=True)
@staticmethod
def _record_type_check_metadata(metadata: dict[str, Any], outcome: OutputTypeCheckOutcome) -> None:
# Surface enough detail in metadata for Inspector / debug logs without
+23 -6
View File
@@ -61,16 +61,33 @@ class WorkflowAgentNodeValidator:
@classmethod
def validate_draft_workflow(cls, *, session: Session, workflow: Workflow) -> None:
cls._validate_workflow(session=session, workflow=workflow, require_binding=False)
cls._validate_workflow(
session=session,
workflow=workflow,
require_binding=False,
validate_previous_node_topology=False,
)
@classmethod
def validate_published_workflow(cls, *, session: Session, workflow: Workflow) -> None:
cls._validate_workflow(session=session, workflow=workflow, require_binding=True)
cls._validate_workflow(
session=session,
workflow=workflow,
require_binding=True,
validate_previous_node_topology=True,
)
@classmethod
def _validate_workflow(cls, *, session: Session, workflow: Workflow, require_binding: bool) -> None:
def _validate_workflow(
cls,
*,
session: Session,
workflow: Workflow,
require_binding: bool,
validate_previous_node_topology: bool,
) -> None:
graph = workflow.graph_dict
topology = _WorkflowGraphTopology.from_graph(graph)
topology = _WorkflowGraphTopology.from_graph(graph) if validate_previous_node_topology else None
for node_id, node_data in cls.iter_agent_v2_nodes(graph):
cls._validate_node_schema(node_id=node_id, node_data=node_data)
binding = cls._find_binding(
@@ -185,12 +202,12 @@ class WorkflowAgentNodeValidator:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has invalid previous node output ref."
)
if topology is None:
continue
if len(selector) < 2:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has incomplete previous node output ref."
)
if topology is None:
continue
source_node_id = selector[0]
if not topology.has_node(source_node_id):
raise WorkflowAgentNodeValidationError(
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.16.0-rc1"
version = "1.16.0"
requires-python = "~=3.12.0"
dependencies = [
@@ -24,6 +24,9 @@ def _create_agent_backend_client():
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
stream_max_reconnects=dify_config.AGENT_BACKEND_STREAM_MAX_RECONNECTS,
stream_run_timeout_seconds=dify_config.AGENT_BACKEND_RUN_TIMEOUT_SECONDS,
)
@@ -1,4 +1,4 @@
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from typing import override
import pytest
@@ -47,6 +47,8 @@ def _request():
class _SuccessfulClient:
stream_options: tuple[int | None, float | None, Callable[[], bool] | None] | None = None
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
assert isinstance(request, CreateRunRequest)
return CreateRunResponse(run_id="run-1", status="running")
@@ -55,8 +57,17 @@ class _SuccessfulClient:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
def stream_events_sync(
self,
run_id: str,
*,
after: str | None = None,
max_reconnects: int | None = None,
timeout_seconds: float | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after
self.stream_options = (max_reconnects, timeout_seconds, should_stop)
yield RunStartedEvent(id="1-0", run_id=run_id)
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
@@ -72,17 +83,22 @@ class _SuccessfulClient:
def test_dify_agent_backend_run_client_delegates_sync_methods():
client = DifyAgentBackendRunClient(_SuccessfulClient())
wrapped = _SuccessfulClient()
client = DifyAgentBackendRunClient(wrapped, stream_max_reconnects=2, stream_timeout_seconds=45)
def should_stop() -> bool:
return False
created = client.create_run(_request())
cancelled = client.cancel_run(created.run_id)
events = list(client.stream_events(created.run_id))
events = list(client.stream_events(created.run_id, should_stop=should_stop))
status = client.wait_run(created.run_id)
assert created.run_id == "run-1"
assert cancelled.status == "cancelled"
assert events[0].type == "run_started"
assert status.status == "succeeded"
assert wrapped.stream_options == (2, 45, should_stop)
def test_dify_agent_backend_run_client_maps_validation_error():
@@ -125,7 +141,16 @@ def test_dify_agent_backend_run_client_maps_timeout_error():
def test_dify_agent_backend_run_client_maps_stream_error():
class StreamClient(_SuccessfulClient):
@override
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
def stream_events_sync(
self,
run_id: str,
*,
after: str | None = None,
max_reconnects: int | None = None,
timeout_seconds: float | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del run_id, after, max_reconnects, timeout_seconds, should_stop
raise DifyAgentStreamError("bad stream")
yield
@@ -100,14 +100,6 @@ class TestModelConfigConverter:
assert result.parameters == {"temperature": 0.7}
assert result.stop == ["\n"]
def test_convert_drops_first_token_timeout_ms(self, mock_app_config, patch_provider_manager):
"""The workflow-only setting must never reach providers through app configs."""
mock_app_config.model.parameters = {"temperature": 0.7, "first_token_timeout_ms": 2000}
result = ModelConfigConverter.convert(mock_app_config)
assert result.parameters == {"temperature": 0.7}
def test_convert_mode_from_schema_valid(self, mock_app_config, mock_provider_bundle, mocker: MockerFixture):
mock_app_config.model.mode = None
@@ -4,7 +4,7 @@ saved, using the deterministic fake backend client (no live stack)."""
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any, override
@@ -106,8 +106,14 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -138,8 +144,14 @@ class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
class _StreamingRecordingFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -173,8 +185,14 @@ class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgent
self._queue_manager = queue_manager
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -196,8 +214,14 @@ class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgent
class _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -220,8 +244,14 @@ class _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient(FakeAgentBacken
class _NullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield RunSucceededEvent(
@@ -237,8 +267,14 @@ class _NullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient):
class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -261,8 +297,14 @@ class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClien
class _AgentAnswerStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -292,8 +334,14 @@ class _AgentAnswerStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
del after
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
yield PydanticAIStreamRunEvent(
@@ -61,16 +61,37 @@ class TestBaseAppQueueManager:
manager._check_for_sqlalchemy_models(bad)
def test_stop_listen_defers_graph_runtime_state_cleanup_until_listener_exits(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
with (
patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis,
patch("core.app.apps.base_app_queue_manager.GraphEngineManager") as graph_engine_manager,
):
mock_redis.setex.return_value = True
mock_redis.get.return_value = None
manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API)
runtime_state = SimpleNamespace(name="runtime-state")
manager.graph_runtime_state = runtime_state
runtime_state = SimpleNamespace(name="runtime-state")
manager.graph_runtime_state = runtime_state
manager.stop_listen()
manager.stop_listen()
assert manager.graph_runtime_state is runtime_state
assert list(manager.listen()) == []
assert manager.graph_runtime_state is None
graph_engine_manager.return_value.send_stop_command.assert_called_once_with(
"t1",
reason="Client response stream closed before app execution completed",
)
assert manager.graph_runtime_state is runtime_state
assert list(manager.listen()) == []
assert manager.graph_runtime_state is None
def test_abort_execution_is_idempotent_when_graph_stop_command_fails(self, caplog):
with (
patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis,
patch("core.app.apps.base_app_queue_manager.GraphEngineManager") as graph_engine_manager,
):
mock_redis.setex.return_value = True
graph_engine_manager.return_value.send_stop_command.side_effect = RuntimeError("redis unavailable")
manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API)
manager._abort_execution("stream closed")
manager._abort_execution("duplicate")
graph_engine_manager.return_value.send_stop_command.assert_called_once_with("t1", reason="stream closed")
assert "Failed to abort app execution for task t1" in caplog.text
@@ -17,6 +17,7 @@ from core.app.entities.queue_entities import (
QueueNodeRetryEvent,
QueueNodeSucceededEvent,
QueueReasoningChunkEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
@@ -27,6 +28,7 @@ from core.workflow.system_variables import default_system_variables
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes
from graphon.graph_events import (
GraphRunAbortedEvent,
GraphRunPausedEvent,
GraphRunStartedEvent,
GraphRunSucceededEvent,
@@ -373,6 +375,23 @@ class TestWorkflowBasedAppRunner:
assert paused_event.paused_nodes == ["node-1"]
assert emails
def test_handle_graph_aborted_publishes_stopped_terminal(self):
published: list[object] = []
class _QueueManager:
def publish(self, event, publish_from):
del publish_from
published.append(event)
runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app")
workflow_entry = SimpleNamespace()
runner._handle_event(workflow_entry, GraphRunAbortedEvent(reason="User requested stop", outputs={}))
event = published[-1]
assert isinstance(event, QueueStopEvent)
assert event.get_stop_reason() == "User requested stop"
def test_handle_node_events_publishes_queue_events(self):
published: list[object] = []
@@ -5,7 +5,7 @@ from unittest.mock import patch
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueMessageEndEvent, QueuePingEvent
from core.app.entities.queue_entities import QueueMessageEndEvent, QueuePingEvent, QueueStopEvent
class TestWorkflowAppQueueManager:
@@ -35,3 +35,67 @@ class TestWorkflowAppQueueManager:
)
manager._publish(QueuePingEvent(), PublishFrom.TASK_PIPELINE)
def test_listener_close_aborts_unfinished_execution(self):
with (
patch("core.app.apps.base_app_queue_manager.redis_client") as redis_client,
patch("core.app.apps.base_app_queue_manager.GraphEngineManager") as graph_engine_manager,
):
redis_client.get.return_value = None
manager = WorkflowAppQueueManager(
task_id="task",
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
app_mode="workflow",
)
manager.publish(QueuePingEvent(), PublishFrom.TASK_PIPELINE)
listener = manager.listen()
assert isinstance(next(listener).event, QueuePingEvent)
listener.close()
graph_engine_manager.return_value.send_stop_command.assert_called_once_with(
"task",
reason="Client response stream closed before app execution completed",
)
def test_execution_timeout_aborts_graph_before_stop_event(self):
with (
patch("core.app.apps.base_app_queue_manager.redis_client") as redis_client,
patch("core.app.apps.base_app_queue_manager.GraphEngineManager") as graph_engine_manager,
patch("core.app.apps.base_app_queue_manager.dify_config.APP_MAX_EXECUTION_TIME", 0),
):
redis_client.get.return_value = None
manager = WorkflowAppQueueManager(
task_id="task",
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
app_mode="workflow",
)
manager.publish(QueuePingEvent(), PublishFrom.TASK_PIPELINE)
messages = list(manager.listen())
assert any(isinstance(message.event, QueueStopEvent) for message in messages)
graph_engine_manager.return_value.send_stop_command.assert_called_once_with(
"task",
reason="App execution exceeded 0 seconds",
)
def test_terminal_event_does_not_abort_completed_execution(self):
with (
patch("core.app.apps.base_app_queue_manager.redis_client") as redis_client,
patch("core.app.apps.base_app_queue_manager.GraphEngineManager") as graph_engine_manager,
):
redis_client.get.return_value = None
manager = WorkflowAppQueueManager(
task_id="task",
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
app_mode="workflow",
)
manager.publish(QueueMessageEndEvent(llm_result=None), PublishFrom.APPLICATION_MANAGER)
_ = list(manager.listen())
graph_engine_manager.return_value.send_stop_command.assert_not_called()
@@ -6,6 +6,13 @@ class TestQueueEntities:
event = QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL)
assert event.get_stop_reason() == "Stopped by user."
def test_get_stop_reason_prefers_explicit_reason(self):
event = QueueStopEvent(
stopped_by=QueueStopEvent.StopBy.USER_MANUAL,
reason="Workflow execution timed out",
)
assert event.get_stop_reason() == "Workflow execution timed out"
def test_get_stop_reason_for_unknown_stop_by(self):
event = QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL)
event.stopped_by = "unknown"
@@ -1,215 +0,0 @@
import httpx
import pytest
from pytest_mock import MockerFixture
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl import base as base_mod
from core.plugin.impl.first_token_timeout import FirstTokenTimeoutError, first_token_timeout_ctx
BasePluginClient = base_mod.BasePluginClient
@pytest.fixture(autouse=True)
def _isolate_ctx():
"""Keep the first-token-timeout ContextVar from leaking between tests."""
token = first_token_timeout_ctx.set(None)
try:
yield
finally:
first_token_timeout_ctx.reset(token)
class _PlainStream:
"""Fully buffered fake httpx stream context."""
def __init__(self, lines: list[object]) -> None:
self._lines = lines
def __enter__(self) -> "_PlainStream":
return self
def __exit__(self, *exc: object) -> bool:
return False
def iter_lines(self):
return iter(self._lines)
class _RaiseOnEnterStream:
"""Fake stream whose context entry raises — models a timeout while awaiting headers."""
def __init__(self, exc: BaseException) -> None:
self._exc = exc
def __enter__(self) -> "_RaiseOnEnterStream":
raise self._exc
def __exit__(self, *exc: object) -> bool:
return False
class _LinesThenRaiseStream:
"""Yields some lines, then raises — models a stall after the first token(s)."""
def __init__(self, lines: list[object], exc: BaseException) -> None:
self._lines = lines
self._exc = exc
def __enter__(self) -> "_LinesThenRaiseStream":
return self
def __exit__(self, *exc: object) -> bool:
return False
def iter_lines(self):
yield from self._lines
raise self._exc
# --- _read_timeout_for ------------------------------------------------------------------
def test_read_timeout_for_narrows_read_only() -> None:
base = base_mod.plugin_daemon_request_timeout
timeout = base_mod._read_timeout_for(5.0)
assert timeout is not base
assert timeout.read == 5.0
# The other components are preserved from the default timeout.
assert timeout.connect == base.connect
assert timeout.write == base.write
assert timeout.pool == base.pool
@pytest.mark.parametrize("disabled", [None, 0.0, -1.0])
def test_read_timeout_for_disabled_returns_base_unchanged(disabled: float | None) -> None:
assert base_mod._read_timeout_for(disabled) is base_mod.plugin_daemon_request_timeout
def test_read_timeout_for_with_no_base_timeout(mocker: MockerFixture) -> None:
mocker.patch("core.plugin.impl.base.plugin_daemon_request_timeout", None)
timeout = base_mod._read_timeout_for(5.0)
assert timeout.read == 5.0
assert timeout.connect is None
assert timeout.write is None
assert timeout.pool is None
# --- _stream_request timeout wiring ------------------------------------------------------
def test_stream_request_narrows_read_when_gate_enabled(mocker: MockerFixture) -> None:
client = BasePluginClient()
stream = mocker.patch("httpx.Client.stream", return_value=_PlainStream([b"data: hi"]))
first_token_timeout_ctx.set(1.5)
result = list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
assert result == ["hi"]
assert stream.call_args.kwargs["timeout"].read == 1.5
@pytest.mark.parametrize("disabled", [None, 0.0])
def test_stream_request_keeps_default_timeout_when_gate_disabled(mocker: MockerFixture, disabled: float | None) -> None:
client = BasePluginClient()
stream = mocker.patch("httpx.Client.stream", return_value=_PlainStream([b"data: hi"]))
first_token_timeout_ctx.set(disabled)
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
assert stream.call_args.kwargs["timeout"] is base_mod.plugin_daemon_request_timeout
def test_stream_request_forwards_all_lines(mocker: MockerFixture) -> None:
client = BasePluginClient()
mocker.patch("httpx.Client.stream", return_value=_PlainStream([b"", b"data: hello", "world"]))
first_token_timeout_ctx.set(1.0)
result = list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
assert result == ["hello", "world"]
# --- _stream_request timeout semantics ---------------------------------------------------
def test_read_timeout_before_first_line_raises_first_token_timeout(mocker: MockerFixture) -> None:
client = BasePluginClient()
mocker.patch("httpx.Client.stream", return_value=_RaiseOnEnterStream(httpx.ReadTimeout("headers")))
first_token_timeout_ctx.set(0.5)
with pytest.raises(FirstTokenTimeoutError):
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
def test_read_timeout_with_gate_disabled_is_transport_error(mocker: MockerFixture) -> None:
client = BasePluginClient()
mocker.patch("httpx.Client.stream", return_value=_RaiseOnEnterStream(httpx.ReadTimeout("headers")))
# ctx is None (autouse fixture) -> gate off -> a read timeout is just a transport error.
with pytest.raises(PluginDaemonInnerError):
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
def test_read_timeout_after_first_line_is_transport_error(mocker: MockerFixture) -> None:
client = BasePluginClient()
mocker.patch(
"httpx.Client.stream",
return_value=_LinesThenRaiseStream([b"data: hello"], httpx.ReadTimeout("inter-token")),
)
first_token_timeout_ctx.set(0.5)
# First token already seen -> a later read timeout is an inter-token stall, not a
# first-token timeout.
with pytest.raises(PluginDaemonInnerError) as exc_info:
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
assert "0.5s first-token timeout window" in exc_info.value.message
def test_read_timeout_after_first_line_with_gate_off_keeps_plain_message(mocker: MockerFixture) -> None:
client = BasePluginClient()
mocker.patch(
"httpx.Client.stream",
return_value=_LinesThenRaiseStream([b"data: hello"], httpx.ReadTimeout("inter-token")),
)
# ctx is None (autouse fixture) -> gate off -> no window hint in the message.
with pytest.raises(PluginDaemonInnerError) as exc_info:
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
assert "first-token timeout window" not in exc_info.value.message
def test_non_timeout_request_error_is_transport_error(mocker: MockerFixture) -> None:
client = BasePluginClient()
mocker.patch("httpx.Client.stream", return_value=_RaiseOnEnterStream(httpx.ConnectError("boom")))
first_token_timeout_ctx.set(0.5)
# Only a ReadTimeout maps to FirstTokenTimeoutError; other transport errors do not.
with pytest.raises(PluginDaemonInnerError):
list(client._stream_request("POST", "plugin/tenant/stream", data={"k": "v"}))
# --- graphon error-transform contract -----------------------------------------------------
def test_first_token_timeout_error_survives_graphon_invoke_error_transform() -> None:
"""error_type == "FirstTokenTimeoutError" relies on graphon passing the exception
through ``_transform_invoke_error`` unchanged (``InvokeError`` subclasses
``ValueError``, whose mapping entry returns the original error). A graphon bump
breaking either fact would silently degrade the type — fail loudly here instead.
"""
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.model_runtime.model_providers.base.ai_model import AIModel
class _ProbeModel:
_invoke_error_mapping = AIModel._invoke_error_mapping
provider_display_name = "probe"
assert issubclass(InvokeError, ValueError)
error = FirstTokenTimeoutError("The first token was not received within 1.5s.")
transformed = AIModel._transform_invoke_error(_ProbeModel(), error) # type: ignore[arg-type]
assert transformed is error
@@ -1,3 +1,4 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import cast
@@ -5,11 +6,21 @@ from unittest.mock import MagicMock, patch
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import AskHumanToolResult
from dify_agent.protocol import PydanticAIStreamRunEvent, RunStartedEvent, RunSucceededEvent, RunSucceededEventData
from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
PydanticAIStreamRunEvent,
RunEvent,
RunStartedEvent,
RunSucceededEvent,
RunSucceededEventData,
)
from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
from clients.agent_backend import (
AgentBackendInternalEventType,
AgentBackendRunEventAdapter,
AgentBackendStreamError,
AgentBackendStreamInternalEvent,
FakeAgentBackendRunClient,
FakeAgentBackendScenario,
@@ -216,6 +227,59 @@ class AgentMessageDeltaBackendClient(FakeAgentBackendRunClient):
)
class FailingStreamBackendClient(FakeAgentBackendRunClient):
def __init__(self) -> None:
super().__init__()
self.cancel_requests: list[CancelRunRequest | None] = []
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del run_id, after, should_stop
raise AgentBackendStreamError("stream reconnect attempts exhausted")
yield
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
self.cancel_requests.append(request)
return CancelRunResponse(run_id=run_id, status="cancelled")
class EmptyStreamBackendClient(FailingStreamBackendClient):
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del run_id, after, should_stop
return
yield
class GenericFailingStreamBackendClient(FailingStreamBackendClient):
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del run_id, after, should_stop
raise RuntimeError("unexpected stream failure")
yield
class CancelFailingStreamBackendClient(FailingStreamBackendClient):
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
self.cancel_requests.append(request)
raise RuntimeError(f"failed to cancel {run_id}")
def _node(
*,
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
@@ -668,6 +732,78 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch):
assert client.request is None # no second Agent run was created
def test_agent_node_cancels_backend_run_when_stream_fails():
client = FailingStreamBackendClient()
node = _node(agent_backend_client=client)
terminal, failure = node._consume_event_stream("run-1", {"agent_backend": {}})
assert terminal is None
assert failure is not None
assert len(client.cancel_requests) == 1
assert client.cancel_requests[0] is not None
assert client.cancel_requests[0].reason == "event_stream_failed"
def test_agent_node_cancels_backend_run_when_stream_ends_without_terminal_event():
client = EmptyStreamBackendClient()
node = _node(agent_backend_client=client)
terminal, failure = node._consume_event_stream("run-1", {"agent_backend": {}})
assert terminal is None
assert failure is None
assert client.cancel_requests[0] is not None
assert client.cancel_requests[0].reason == "stream_ended_without_terminal_event"
def test_agent_node_cancels_backend_run_when_stream_raises_unexpected_error():
client = GenericFailingStreamBackendClient()
node = _node(agent_backend_client=client)
terminal, failure = node._consume_event_stream("run-1", {"agent_backend": {}})
assert terminal is None
assert failure is not None
assert failure.node_run_result.error == "unexpected stream failure"
assert client.cancel_requests[0] is not None
assert client.cancel_requests[0].reason == "event_stream_failed"
def test_agent_node_uses_graph_abort_reason_when_cancel_request_fails(caplog):
client = CancelFailingStreamBackendClient()
node = _node(agent_backend_client=client)
node.graph_runtime_state.graph_execution = SimpleNamespace(aborted=True)
terminal, failure = node._consume_event_stream("run-1", {"agent_backend": {}})
assert terminal is None
assert failure is not None
assert client.cancel_requests[0] is not None
assert client.cancel_requests[0].reason == "workflow_graph_aborted"
assert "Failed to cancel Workflow Agent backend run" in caplog.text
def test_agent_node_cancels_backend_run_for_unexpected_internal_event():
client = FakeAgentBackendRunClient()
node = _node(agent_backend_client=client)
node._agent_backend_client.cancel_run = MagicMock( # type: ignore[method-assign]
return_value=CancelRunResponse(run_id="run-1", status="cancelled")
)
node._event_adapter.adapt = MagicMock( # type: ignore[method-assign]
return_value=[SimpleNamespace(type=AgentBackendInternalEventType.RUN_FAILED)]
)
terminal, failure = node._consume_event_stream("run-1", {"agent_backend": {}})
assert terminal is None
assert failure is not None
assert failure.node_run_result.error == (
"Unexpected internal event type <AgentBackendInternalEventType.RUN_FAILED: 'run_failed'>"
)
node._agent_backend_client.cancel_run.assert_called_once()
def test_agent_node_records_stream_usage_metadata():
metadata = {"agent_backend": {"run_id": "run-1"}}
@@ -187,6 +187,51 @@ def test_draft_validation_allows_unbound_agent_node():
)
def test_draft_validation_allows_missing_previous_node():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]}
)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()]
WorkflowAgentNodeValidator.validate_draft_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_draft_validation_allows_non_upstream_previous_output_ref():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "later-node", "output": "text"}]}
)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()]
WorkflowAgentNodeValidator.validate_draft_workflow(
session=session,
workflow=_workflow(
_graph(
[
{"source": "start", "target": "agent-node"},
{"source": "agent-node", "target": "later-node"},
]
)
),
)
def test_draft_validation_rejects_incomplete_previous_output_ref():
node_job = WorkflowNodeJobConfig.model_validate({"previous_node_output_refs": [{"selector": ["previous-node"]}]})
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), _snapshot()]
with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete previous node output ref"):
WorkflowAgentNodeValidator.validate_draft_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_publish_validation_requires_binding():
session = Mock()
session.scalar.return_value = None
@@ -295,7 +295,6 @@ def test_fetch_model_config_hydrates_model_instance_runtime_settings(model_confi
"temperature": 0.7,
"max_tokens": 256,
"stop": ["Observation:", "Human:"],
"first_token_timeout_ms": 30000,
}
model_instance = mock.MagicMock(
@@ -337,7 +336,6 @@ def test_fetch_model_config_hydrates_model_instance_runtime_settings(model_confi
"max_tokens": 256,
}
assert hydrated_model_instance.stop == ("Observation:", "Human:")
assert hydrated_model_instance.first_token_timeout == 30.0
assert model_config_with_credentials.parameters == {
"temperature": 0.7,
"max_tokens": 256,
@@ -347,51 +345,12 @@ def test_fetch_model_config_hydrates_model_instance_runtime_settings(model_confi
"temperature": 0.7,
"max_tokens": 256,
"stop": ["Observation:", "Human:"],
"first_token_timeout_ms": 30000,
}
mock_credentials_provider.fetch.assert_called_once_with("openai", "gpt-3.5-turbo")
mock_model_factory.init_model_instance.assert_called_once_with("openai", "gpt-3.5-turbo")
provider_model.raise_for_status.assert_called_once()
@pytest.mark.parametrize(
("raw_timeout_ms", "expected"),
[
(30000, 30.0),
(1500, 1.5),
(100, 0.1),
(0, None),
(-5, None),
(True, None),
("30000", None),
(None, None),
],
)
def test_normalize_completion_params_extracts_first_token_timeout(raw_timeout_ms: object, expected: float | None):
from core.app.llm.model_access import _normalize_completion_params
completion_params = {"temperature": 0.7, "first_token_timeout_ms": raw_timeout_ms}
parameters, stop, first_token_timeout = _normalize_completion_params(completion_params)
assert first_token_timeout == expected
assert "first_token_timeout_ms" not in parameters
assert parameters == {"temperature": 0.7}
assert stop == []
# The caller's dict is left untouched.
assert completion_params == {"temperature": 0.7, "first_token_timeout_ms": raw_timeout_ms}
def test_normalize_completion_params_without_first_token_timeout_key():
from core.app.llm.model_access import _normalize_completion_params
parameters, stop, first_token_timeout = _normalize_completion_params({"temperature": 0.7, "stop": ["Human:"]})
assert first_token_timeout is None
assert parameters == {"temperature": 0.7}
assert stop == ["Human:"]
def test_fetch_model_config_reuses_validated_provider_model_from_dify_credentials_provider(
model_config: ModelConfigWithCredentialsEntity,
):
@@ -8,7 +8,6 @@ from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunC
from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access
from core.llm_generator.output_parser.errors import OutputParserError
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
from core.plugin.impl.first_token_timeout import first_token_timeout_ctx
from core.plugin.impl.model import PluginModelClient
from core.plugin.impl.model_runtime import PluginModelRuntime
from core.plugin.plugin_service import PluginService
@@ -83,7 +82,6 @@ class _ModelInstanceStub:
self.model_name = "gpt-4o-mini"
self.parameters = {"temperature": 0.2}
self.stop = ("stop",)
self.first_token_timeout: float | None = None
self.credentials = {"api_key": "secret"}
self.model_type_instance = _ModelTypeInstanceStub(
model_schema=model_schema,
@@ -203,164 +201,6 @@ def test_dify_prepared_llm_wraps_model_instance_calls() -> None:
)
def test_dify_prepared_llm_sets_first_token_timeout_ctx_during_streaming() -> None:
observed: list[float | None] = []
def _fake_invoke(**_kwargs: object):
def _gen():
observed.append(first_token_timeout_ctx.get())
yield "chunk-1"
observed.append(first_token_timeout_ctx.get())
yield "chunk-2"
return _gen()
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
model_instance.first_token_timeout = 1.5
prepared = DifyPreparedLLM(model_instance)
generator = prepared.invoke_llm(
prompt_messages=[],
model_parameters={},
tools=None,
stop=None,
stream=True,
)
# The ContextVar is set inside the generator (during iteration), not on this frame.
assert first_token_timeout_ctx.get() is None
result = list(generator)
assert result == ["chunk-1", "chunk-2"]
assert observed == [1.5, 1.5]
# Reset once the generator is exhausted.
assert first_token_timeout_ctx.get() is None
def test_dify_prepared_llm_leaves_ctx_unset_when_no_first_token_timeout() -> None:
observed: list[float | None] = []
def _fake_invoke(**_kwargs: object):
def _gen():
observed.append(first_token_timeout_ctx.get())
yield "chunk"
return _gen()
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
prepared = DifyPreparedLLM(model_instance)
result = list(
prepared.invoke_llm(
prompt_messages=[],
model_parameters={},
tools=None,
stop=None,
stream=True,
)
)
assert result == ["chunk"]
assert observed == [None]
def test_dify_prepared_llm_sets_first_token_timeout_ctx_for_non_stream_eager_path() -> None:
observed: list[float | None] = []
def _fake_invoke(**_kwargs: object):
# A non-stream call resolves eagerly; the ContextVar must be set during this call.
observed.append(first_token_timeout_ctx.get())
return sentinel.result
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
model_instance.first_token_timeout = 1.5
prepared = DifyPreparedLLM(model_instance)
result = prepared.invoke_llm(
prompt_messages=[],
model_parameters={},
tools=None,
stop=None,
stream=False,
)
# The non-generator result is returned unchanged, and the gate is reset once it returns.
assert result is sentinel.result
assert observed == [1.5]
assert first_token_timeout_ctx.get() is None
def test_dify_prepared_llm_propagates_first_token_timeout_ctx_through_structured_output(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: list[float | None] = []
def _fake_structured(**_kwargs: object):
def _gen():
observed.append(first_token_timeout_ctx.get())
yield "chunk-1"
observed.append(first_token_timeout_ctx.get())
yield "chunk-2"
return _gen()
monkeypatch.setattr(node_runtime, "invoke_llm_with_structured_output", _fake_structured)
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
model_instance.first_token_timeout = 1.5
prepared = DifyPreparedLLM(model_instance)
generator = prepared.invoke_llm_with_structured_output(
prompt_messages=[],
json_schema={"type": "object"},
model_parameters={},
stop=None,
stream=True,
)
# Lazy: the ContextVar is set during iteration, not on this frame.
assert first_token_timeout_ctx.get() is None
result = list(generator)
assert result == ["chunk-1", "chunk-2"]
assert observed == [1.5, 1.5]
assert first_token_timeout_ctx.get() is None
def test_dify_prepared_llm_resets_first_token_timeout_ctx_when_stream_raises() -> None:
observed: list[float | None] = []
def _fake_invoke(**_kwargs: object):
def _gen():
observed.append(first_token_timeout_ctx.get())
yield "chunk-1"
raise RuntimeError("boom")
return _gen()
model_instance = _ModelInstanceStub(model_schema=_build_model_schema())
model_instance.invoke_llm = _fake_invoke # type: ignore[assignment]
model_instance.first_token_timeout = 1.5
prepared = DifyPreparedLLM(model_instance)
generator = prepared.invoke_llm(
prompt_messages=[],
model_parameters={},
tools=None,
stop=None,
stream=True,
)
with pytest.raises(RuntimeError, match="boom"):
list(generator)
# The try/finally in _guarded_stream resets the ContextVar even when iteration errors out.
assert observed == [1.5]
assert first_token_timeout_ctx.get() is None
def test_dify_prepared_llm_requires_model_schema() -> None:
model_instance = _ModelInstanceStub(model_schema=None)
model_instance.credentials = {}
@@ -13,7 +13,9 @@ from services.trigger.webhook_service import WebhookService
class TestWebhookServiceUnit:
"""Unit tests for WebhookService focusing on business logic without database dependencies."""
def test_trigger_workflow_execution_propagates_quota_error_without_error_log(self):
def test_trigger_workflow_execution_propagates_quota_error_without_error_log(
self, caplog: pytest.LogCaptureFixture
):
webhook_trigger = MagicMock(
webhook_id="webhook-123",
tenant_id="tenant-123",
@@ -24,6 +26,7 @@ class TestWebhookServiceUnit:
quota_charge = MagicMock()
quota_error = QuotaExceededError(feature="workflow", tenant_id="tenant-123", required=1)
caplog.set_level(logging.INFO)
with (
patch(
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
@@ -36,8 +39,6 @@ class TestWebhookServiceUnit:
"services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async",
side_effect=quota_error,
),
patch("services.trigger.webhook_service.logger.info") as mock_log_info,
patch("services.trigger.webhook_service.logger.exception") as mock_log_exception,
):
with pytest.raises(QuotaExceededError) as exc_info:
WebhookService.trigger_workflow_execution(
@@ -48,13 +49,13 @@ class TestWebhookServiceUnit:
assert exc_info.value is quota_error
quota_charge.refund.assert_called_once_with()
mock_log_info.assert_called_once_with(
"Tenant %s quota exceeded for feature %s, skipping webhook trigger %s",
webhook_trigger.tenant_id,
quota_error.feature,
webhook_trigger.webhook_id,
# Verify logs using caplog instead of mock_log
assert len(caplog.records) == 1
assert caplog.records[0].levelno == logging.INFO
assert caplog.records[0].message == (
"Tenant tenant-123 quota exceeded for feature workflow, skipping webhook trigger webhook-123"
)
mock_log_exception.assert_not_called()
def test_extract_webhook_data_json(self):
"""Test webhook data extraction from JSON request."""
Generated
+2 -2
View File
@@ -1281,7 +1281,7 @@ wheels = [
[[package]]
name = "dify-agent"
version = "1.16.0rc1"
version = "1.16.0"
source = { editable = "../dify-agent" }
dependencies = [
{ name = "httpx" },
@@ -1331,7 +1331,7 @@ docs = [
[[package]]
name = "dify-api"
version = "1.16.0rc1"
version = "1.16.0"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
+1 -1
View File
@@ -83,7 +83,7 @@ export default class MyCommand extends DifyCommand {
process.stdout.write(
await runMyThing(
{
/* args */
// args
},
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
),
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-agent"
version = "1.16.0-rc1"
version = "1.16.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12,<4.0"
+80 -16
View File
@@ -15,7 +15,7 @@ import asyncio
import inspect
import json
import time
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator
from json import JSONDecodeError
from types import TracebackType
from typing import Any, Self, TypeVar, cast
@@ -277,7 +277,7 @@ class Client:
*,
base_url: str,
timeout: float | httpx.Timeout = 30.0,
stream_timeout: float | httpx.Timeout | None = None,
stream_timeout: float | httpx.Timeout | None = 30.0,
headers: dict[str, str] | None = None,
sync_http_client: httpx.Client | None = None,
async_http_client: httpx.AsyncClient | None = None,
@@ -531,9 +531,11 @@ class Client:
*,
after: str | None = None,
reconnect: bool = True,
max_reconnects: int | None = None,
max_reconnects: int | None = 3,
reconnect_delay_seconds: float = 1.0,
until_terminal: bool = True,
timeout_seconds: float | None = None,
should_stop: Callable[[], bool] | None = None,
) -> AsyncIterator[RunEvent]:
"""Yield typed events from SSE with cursor-based reconnect.
@@ -541,14 +543,21 @@ class Client:
with an id, reconnects resume from that id using the ``after`` query
parameter. HTTP 5xx stream responses are retried, but HTTP 4xx responses,
DTO validation failures, and malformed SSE frames are not retried. By
default iteration stops after ``run_succeeded`` or ``run_failed``.
default iteration stops after a succeeded, failed, or cancelled terminal event.
"""
_validate_stream_options(max_reconnects, reconnect_delay_seconds)
_validate_stream_options(max_reconnects, reconnect_delay_seconds, timeout_seconds)
cursor = after or "0-0"
reconnect_attempts = 0
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
while True:
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
try:
async for event in self._stream_events_once(run_id, after=cursor):
async for event in self._stream_events_once(
run_id,
after=cursor,
deadline=deadline,
should_stop=should_stop,
):
if event.id is not None:
cursor = event.id
yield event
@@ -562,7 +571,8 @@ class Client:
max_reconnects=max_reconnects,
error=exc.error,
)
await _sleep_async(reconnect_delay_seconds)
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
await _sleep_async(_bounded_sleep_seconds(reconnect_delay_seconds, deadline))
continue
if not reconnect:
return
@@ -571,7 +581,8 @@ class Client:
max_reconnects=max_reconnects,
error=DifyAgentStreamError("SSE stream ended before a terminal event"),
)
await _sleep_async(reconnect_delay_seconds)
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
await _sleep_async(_bounded_sleep_seconds(reconnect_delay_seconds, deadline))
def stream_events_sync(
self,
@@ -579,17 +590,26 @@ class Client:
*,
after: str | None = None,
reconnect: bool = True,
max_reconnects: int | None = None,
max_reconnects: int | None = 3,
reconnect_delay_seconds: float = 1.0,
until_terminal: bool = True,
timeout_seconds: float | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
"""Synchronous variant of ``stream_events`` with the same reconnect rules."""
_validate_stream_options(max_reconnects, reconnect_delay_seconds)
_validate_stream_options(max_reconnects, reconnect_delay_seconds, timeout_seconds)
cursor = after or "0-0"
reconnect_attempts = 0
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
while True:
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
try:
for event in self._stream_events_once_sync(run_id, after=cursor):
for event in self._stream_events_once_sync(
run_id,
after=cursor,
deadline=deadline,
should_stop=should_stop,
):
if event.id is not None:
cursor = event.id
yield event
@@ -603,7 +623,8 @@ class Client:
max_reconnects=max_reconnects,
error=exc.error,
)
_sleep_sync(reconnect_delay_seconds)
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
_sleep_sync(_bounded_sleep_seconds(reconnect_delay_seconds, deadline))
continue
if not reconnect:
return
@@ -612,7 +633,8 @@ class Client:
max_reconnects=max_reconnects,
error=DifyAgentStreamError("SSE stream ended before a terminal event"),
)
_sleep_sync(reconnect_delay_seconds)
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
_sleep_sync(_bounded_sleep_seconds(reconnect_delay_seconds, deadline))
async def wait_run(
self,
@@ -652,7 +674,14 @@ class Client:
raise DifyAgentTimeoutError(f"run {run_id!r} did not finish before timeout")
_sleep_sync(sleep_for)
async def _stream_events_once(self, run_id: str, *, after: str) -> AsyncIterator[RunEvent]:
async def _stream_events_once(
self,
run_id: str,
*,
after: str,
deadline: float | None,
should_stop: Callable[[], bool] | None,
) -> AsyncIterator[RunEvent]:
"""Open one SSE connection and yield events until it ends or fails."""
try:
async with self._get_async_http_client().stream(
@@ -668,6 +697,7 @@ class Client:
decoder = _SSEDecoder()
line_decoder = _SSELineDecoder()
async for text in response.aiter_text():
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
for line in line_decoder.decode(text):
event = decoder.feed_line(line)
if event is not None:
@@ -687,7 +717,14 @@ class Client:
except httpx.StreamError as exc:
raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc
def _stream_events_once_sync(self, run_id: str, *, after: str) -> Iterator[RunEvent]:
def _stream_events_once_sync(
self,
run_id: str,
*,
after: str,
deadline: float | None,
should_stop: Callable[[], bool] | None,
) -> Iterator[RunEvent]:
"""Open one synchronous SSE connection and yield events until it ends or fails."""
try:
with self._get_sync_http_client().stream(
@@ -703,6 +740,7 @@ class Client:
decoder = _SSEDecoder()
line_decoder = _SSELineDecoder()
for text in response.iter_text():
_raise_if_stream_stopped(run_id, deadline=deadline, should_stop=should_stop)
for line in line_decoder.decode(text):
event = decoder.feed_line(line)
if event is not None:
@@ -852,12 +890,38 @@ def _next_reconnect_attempt(
return reconnect_attempts + 1
def _validate_stream_options(max_reconnects: int | None, reconnect_delay_seconds: float) -> None:
def _validate_stream_options(
max_reconnects: int | None,
reconnect_delay_seconds: float,
timeout_seconds: float | None,
) -> None:
"""Reject stream options that cannot produce deterministic reconnect behavior."""
if max_reconnects is not None and max_reconnects < 0:
raise DifyAgentValidationError(detail="max_reconnects must be non-negative")
if reconnect_delay_seconds < 0:
raise DifyAgentValidationError(detail="reconnect_delay_seconds must be non-negative")
if timeout_seconds is not None and timeout_seconds < 0:
raise DifyAgentValidationError(detail="timeout_seconds must be non-negative")
def _raise_if_stream_stopped(
run_id: str,
*,
deadline: float | None,
should_stop: Callable[[], bool] | None,
) -> None:
"""Stop a live stream when its caller cancels or its total deadline expires."""
if should_stop is not None and should_stop():
raise DifyAgentStreamError(f"SSE stream for run {run_id!r} was cancelled by the caller")
if deadline is not None and time.monotonic() >= deadline:
raise DifyAgentTimeoutError(f"SSE stream for run {run_id!r} exceeded its timeout")
def _bounded_sleep_seconds(seconds: float, deadline: float | None) -> float:
"""Keep reconnect backoff inside the total stream deadline."""
if deadline is None:
return seconds
return max(0.0, min(seconds, deadline - time.monotonic()))
def _validate_wait_options(poll_interval_seconds: float, timeout_seconds: float | None) -> None:
@@ -94,6 +94,7 @@ class DifyShellLayerConfig(LayerConfig):
env: list[DifyShellEnvVarConfig] = Field(default_factory=list)
secret_refs: list[DifyShellSecretRefConfig] = Field(default_factory=list)
sandbox: DifyShellSandboxConfig | None = None
redact_patterns: list[str] = Field(default_factory=list)
__all__ = [
@@ -33,7 +33,7 @@ import logging
import re
import secrets
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
@@ -57,6 +57,7 @@ from dify_agent.adapters.shell.protocols import (
ShellProviderProtocol,
ShellResourceProtocol,
)
from dify_agent.agent_stub.protocol import AGENT_STUB_AUTH_JWE_ENV_VAR
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
@@ -251,6 +252,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
config: DifyShellLayerConfig
shell_provider: ShellProviderProtocol
shell_home_root: str = "/home"
shell_redact_patterns: list[str] = field(default_factory=list)
agent_stub_api_base_url: str | None = None
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
_shell_resource: ShellResourceProtocol | None = None
@@ -269,6 +271,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
*,
shell_provider: ShellProviderProtocol | None,
shell_home_root: str = "/home",
shell_redact_patterns: list[str] | None = None,
agent_stub_api_base_url: str | None = None,
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
) -> Self:
@@ -278,6 +281,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
config=config,
shell_provider=shell_provider,
shell_home_root=_normalize_shell_home_root(shell_home_root),
shell_redact_patterns=shell_redact_patterns or [],
agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory,
)
@@ -417,7 +421,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
exit_code=result.exit_code,
output_path=observation.output_path,
),
observation.text,
self._redact_output(observation.text),
)
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc)
@@ -443,7 +447,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
exit_code=result.exit_code,
output_path=observation.output_path,
),
observation.text,
self._redact_output(observation.text),
)
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id)
@@ -469,7 +473,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
exit_code=result.exit_code,
output_path=observation.output_path,
),
observation.text,
self._redact_output(observation.text),
)
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id)
@@ -718,6 +722,30 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
env.update(agent_stub_env)
return env
def _redact_output(self, text: str) -> str:
"""Redact sensitive content from shell output before the model sees it.
Two layers of redaction are applied:
1. **Built-in token redaction** the actual Agent Stub JWE token value
is always replaced with ``***``. This is unconditional and cannot be
disabled.
2. **Pattern redaction** regex patterns from both server-level
``shell_redact_patterns`` and per-agent ``config.redact_patterns``
are applied via ``re.sub`` to mask additional secrets.
"""
if not text:
return text
# Built-in: always redact the JWE token value.
env = self._build_shell_command_env(include_agent_stub_env=True)
jwe_value = env.get(AGENT_STUB_AUTH_JWE_ENV_VAR)
if jwe_value and len(jwe_value) > 8:
text = text.replace(jwe_value, "***")
# Server-level + per-agent regex patterns.
for pattern in (*self.shell_redact_patterns, *self.config.redact_patterns):
text = re.sub(pattern, "***", text)
return text
async def execute_complete_with_commands(
commands: ShellCommandProtocol,
@@ -69,6 +69,7 @@ def create_default_layer_providers(
inner_api_key: str = "",
shell_provider: ShellProviderProtocol | None = None,
shell_home_root: str = "/home",
shell_redact_patterns: list[str] | None = None,
agent_stub_api_base_url: str | None = None,
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
) -> tuple[DifyAgentLayerProvider, ...]:
@@ -94,6 +95,7 @@ def create_default_layer_providers(
DifyShellLayerConfig.model_validate(config),
shell_provider=shell_provider,
shell_home_root=shell_home_root,
shell_redact_patterns=shell_redact_patterns or [],
agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory,
),
@@ -22,6 +22,8 @@ from dify_agent.protocol.schemas import (
EmptyRunEventData,
PydanticAIStreamRunEvent,
RunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunFailedEvent,
RunFailedEventData,
RunStartedEvent,
@@ -159,10 +161,29 @@ async def emit_run_failed(
)
async def emit_run_cancelled(
sink: RunEventSink,
*,
run_id: str,
reason: str | None = None,
message: str | None = None,
) -> str:
"""Emit the terminal cancellation lifecycle event."""
return await emit_run_event(
sink,
event=RunCancelledEvent(
run_id=run_id,
data=RunCancelledEventData(reason=reason, message=message),
created_at=utc_now(),
),
)
__all__ = [
"InMemoryRunEventSink",
"RunEventSink",
"emit_pydantic_ai_event",
"emit_run_cancelled",
"emit_run_event",
"emit_run_failed",
"emit_run_started",
@@ -20,9 +20,9 @@ from typing import Protocol
import httpx
from agenton.compositor import LayerProviderInput
from dify_agent.protocol.schemas import CreateRunRequest
from dify_agent.protocol.schemas import CancelRunRequest, CancelRunResponse, CreateRunRequest
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.event_sink import RunEventSink, emit_run_failed
from dify_agent.runtime.event_sink import RunEventSink, emit_run_cancelled, emit_run_failed
from dify_agent.runtime.runner import AgentRunRunner
from dify_agent.server.schemas import RunRecord
@@ -33,6 +33,10 @@ class SchedulerStoppingError(RuntimeError):
"""Raised when a create-run request arrives after shutdown has started."""
class RunCancellationConflictError(RuntimeError):
"""Raised when a run exists but can no longer be cancelled by this scheduler."""
class RunStore(RunEventSink, Protocol):
"""Persistence boundary needed by the scheduler."""
@@ -40,6 +44,10 @@ class RunStore(RunEventSink, Protocol):
"""Persist a new run record and return it with status ``running``."""
...
async def get_run(self, run_id: str) -> RunRecord:
"""Return the latest persisted run record."""
...
class RunnableRun(Protocol):
"""Executable unit for one scheduled run."""
@@ -65,6 +73,7 @@ class RunScheduler:
store: RunStore
shutdown_grace_seconds: float
active_tasks: dict[str, asyncio.Task[None]]
cancelled_run_ids: set[str]
stopping: bool
runner_factory: RunRunnerFactory
layer_providers: tuple[LayerProviderInput, ...]
@@ -85,6 +94,7 @@ class RunScheduler:
self.store = store
self.shutdown_grace_seconds = shutdown_grace_seconds
self.active_tasks = {}
self.cancelled_run_ids = set()
self.stopping = False
self.plugin_daemon_http_client = plugin_daemon_http_client
self.dify_api_http_client = dify_api_http_client
@@ -106,9 +116,43 @@ class RunScheduler:
record = await self.store.create_run()
task = asyncio.create_task(self._run_record(record, request), name=f"dify-agent-run-{record.run_id}")
self.active_tasks[record.run_id] = task
task.add_done_callback(lambda _task, run_id=record.run_id: self.active_tasks.pop(run_id, None))
task.add_done_callback(lambda _task, run_id=record.run_id: self._discard_active_run(run_id))
return record
async def cancel_run(self, run_id: str, request: CancelRunRequest) -> CancelRunResponse:
"""Cancel one active task and persist an idempotent cancelled terminal state."""
async with self._lifecycle_lock:
record = await self.store.get_run(run_id)
if record.status == "cancelled":
return CancelRunResponse(run_id=run_id, status="cancelled")
if record.status != "running":
raise RunCancellationConflictError(f"run already finished with status {record.status!r}")
task = self.active_tasks.get(run_id)
if task is None:
raise RunCancellationConflictError("run is not active in this scheduler process")
self.cancelled_run_ids.add(run_id)
_ = task.cancel(request.message or request.reason)
_ = await emit_run_cancelled(
self.store,
run_id=run_id,
reason=request.reason,
message=request.message,
)
await self.store.update_status(run_id, "cancelled", request.message or request.reason)
# Some model/tool stacks can consume one CancelledError. Re-inject it
# after the terminal state is durable without making the HTTP request
# wait for arbitrary third-party cleanup.
for _attempt in range(2):
if task.done():
break
_ = task.cancel(request.message or request.reason)
await asyncio.sleep(0)
if task.done():
self._discard_active_run(run_id)
return CancelRunResponse(run_id=run_id, status="cancelled")
async def shutdown(self) -> None:
"""Stop accepting runs, wait briefly, then cancel and fail unfinished runs."""
async with self._lifecycle_lock:
@@ -121,7 +165,11 @@ class RunScheduler:
if not pending:
return
pending_run_ids = [run_id for run_id, task in tasks_by_run_id.items() if task in pending]
pending_run_ids = [
run_id
for run_id, task in tasks_by_run_id.items()
if task in pending and run_id not in self.cancelled_run_ids
]
for task in pending:
_ = task.cancel()
_ = await asyncio.gather(*pending, return_exceptions=True)
@@ -146,8 +194,13 @@ class RunScheduler:
plugin_daemon_http_client=self.plugin_daemon_http_client,
dify_api_http_client=self.dify_api_http_client,
layer_providers=self.layer_providers,
is_cancelled=lambda: record.run_id in self.cancelled_run_ids,
)
def _discard_active_run(self, run_id: str) -> None:
_ = self.active_tasks.pop(run_id, None)
self.cancelled_run_ids.discard(run_id)
async def _mark_cancelled_run_failed(self, run_id: str) -> None:
"""Best-effort failure event/status for shutdown-cancelled runs."""
message = "run cancelled during server shutdown"
@@ -158,4 +211,4 @@ class RunScheduler:
logger.exception("failed to mark cancelled run failed", extra={"run_id": run_id})
__all__ = ["RunScheduler", "SchedulerStoppingError"]
__all__ = ["RunCancellationConflictError", "RunScheduler", "SchedulerStoppingError"]
@@ -31,6 +31,7 @@ both the JSON-safe final output or deferred tool call and the session snapshot;
there are no separate output or snapshot events to correlate.
"""
import asyncio
from collections.abc import AsyncIterable, Callable, Mapping
from collections import Counter
from dataclasses import dataclass
@@ -170,6 +171,7 @@ class AgentRunRunner:
layer_providers: tuple[LayerProviderInput, ...]
plugin_daemon_http_client: httpx.AsyncClient
dify_api_http_client: httpx.AsyncClient
is_cancelled: Callable[[], bool]
def __init__(
self,
@@ -180,6 +182,7 @@ class AgentRunRunner:
plugin_daemon_http_client: httpx.AsyncClient,
dify_api_http_client: httpx.AsyncClient,
layer_providers: tuple[LayerProviderInput, ...] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> None:
self.sink = sink
self.request = request
@@ -187,20 +190,29 @@ class AgentRunRunner:
self.plugin_daemon_http_client = plugin_daemon_http_client
self.dify_api_http_client = dify_api_http_client
self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
self.is_cancelled = is_cancelled or (lambda: False)
async def run(self) -> None:
"""Execute the run and emit the documented event sequence."""
if self.is_cancelled():
return
await self.sink.update_status(self.run_id, "running")
if self.is_cancelled():
return
_ = await emit_run_started(self.sink, run_id=self.run_id)
try:
outcome = await self._run_agent()
except Exception as exc:
if self.is_cancelled():
return
message, reason = _run_failed_error_payload(exc)
_ = await emit_run_failed(self.sink, run_id=self.run_id, error=message, reason=reason)
await self.sink.update_status(self.run_id, "failed", message)
raise
if self.is_cancelled():
return
_ = await emit_run_succeeded(
self.sink,
run_id=self.run_id,
@@ -309,6 +321,8 @@ class AgentRunRunner:
async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None:
async for event in events:
if self.is_cancelled():
raise asyncio.CancelledError
text_delta = _extract_agent_message_delta(event)
_ = await emit_pydantic_ai_event(
self.sink,
+1
View File
@@ -67,6 +67,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
inner_api_key=resolved_settings.inner_api_key or "",
shell_provider=shell_provider,
shell_home_root=resolved_settings.shell_home_root,
shell_redact_patterns=resolved_settings.get_shell_redact_patterns(),
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory,
)
+13 -11
View File
@@ -24,7 +24,7 @@ from dify_agent.protocol.schemas import (
RunEventsResponse,
RunStatusResponse,
)
from dify_agent.runtime.run_scheduler import RunScheduler, SchedulerStoppingError
from dify_agent.runtime.run_scheduler import RunCancellationConflictError, RunScheduler, SchedulerStoppingError
from dify_agent.server.sse import sse_event_stream
from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError
@@ -68,16 +68,18 @@ def create_runs_router(
)
@router.post("/{run_id}/cancel", response_model=CancelRunResponse)
async def cancel_run(run_id: str, request: CancelRunRequest) -> CancelRunResponse:
"""Reserve the cancellation endpoint in the public protocol.
Runtime cancellation requires scheduler task lookup and persistence
semantics that are outside the current server implementation. Exposing a
typed endpoint now lets clients bind to the final route while receiving
an explicit 501 until execution support lands.
"""
del run_id, request
raise HTTPException(status_code=501, detail="run cancellation is not implemented")
async def cancel_run(
run_id: str,
request: CancelRunRequest,
scheduler: Annotated[RunScheduler, Depends(scheduler_dep)],
) -> CancelRunResponse:
"""Cancel a process-local run and publish its terminal event/status."""
try:
return await scheduler.cancel_run(run_id, request)
except RunNotFoundError as exc:
raise HTTPException(status_code=404, detail="run not found") from exc
except RunCancellationConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/{run_id}/events", response_model=RunEventsResponse)
async def get_run_events(
@@ -52,6 +52,7 @@ class ServerSettings(BaseSettings):
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
server_secret_key: str | None = None
shell_redact_patterns: str = ""
outbound_http_connect_timeout: float = Field(default=10.0, ge=0)
outbound_http_read_timeout: float = Field(default=600.0, ge=0)
outbound_http_write_timeout: float = Field(default=30.0, ge=0)
@@ -126,6 +127,18 @@ class ServerSettings(BaseSettings):
stripped = value.strip()
return stripped or None
def get_shell_redact_patterns(self) -> list[str]:
"""Parse the JSON array from shell_redact_patterns; empty/blank → empty list."""
stripped = self.shell_redact_patterns.strip()
if not stripped:
return []
import json as _json
parsed = _json.loads(stripped)
if not isinstance(parsed, list):
raise ValueError("DIFY_AGENT_SHELL_REDACT_PATTERNS must be a JSON array of strings")
return [str(p) for p in parsed]
@field_validator("shell_home_root")
@classmethod
def normalize_shell_home_root(cls, value: str) -> str:
+28 -4
View File
@@ -5,6 +5,7 @@ browsers can resume with ``Last-Event-ID`` while clients can subscribe by event
name. Payload data is the full public ``RunEvent`` JSON object.
"""
import asyncio
from collections.abc import AsyncIterable, AsyncIterator
from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, RunEvent
@@ -29,10 +30,33 @@ def format_sse_event(event: RunEvent) -> str:
return "\n".join(lines) + "\n\n"
async def sse_event_stream(events: AsyncIterable[RunEvent]) -> AsyncIterator[str]:
"""Yield formatted SSE frames from public run events."""
async for event in events:
yield format_sse_event(event)
async def sse_event_stream(
events: AsyncIterable[RunEvent],
*,
heartbeat_interval_seconds: float = 15.0,
) -> AsyncIterator[str]:
"""Yield events and keep idle SSE connections observable to clients."""
if heartbeat_interval_seconds <= 0:
raise ValueError("heartbeat_interval_seconds must be positive")
iterator = events.__aiter__()
next_event = asyncio.ensure_future(anext(iterator))
try:
while True:
done, _ = await asyncio.wait({next_event}, timeout=heartbeat_interval_seconds)
if not done:
yield ": keepalive\n\n"
continue
try:
event = next_event.result()
except StopAsyncIteration:
return
yield format_sse_event(event)
next_event = asyncio.ensure_future(anext(iterator))
finally:
if not next_event.done():
_ = next_event.cancel()
_ = await asyncio.gather(next_event, return_exceptions=True)
__all__ = ["format_sse_event", "sse_event_stream"]
@@ -589,6 +589,63 @@ def test_stream_events_raises_when_reconnects_are_exhausted() -> None:
assert calls == 2
def test_stream_events_default_reconnect_budget_is_finite() -> None:
calls = 0
def handler(_request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(200, content="")
client = Client(
base_url="http://testserver",
sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
with pytest.raises(DifyAgentStreamError, match="reconnect attempts exhausted"):
_ = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0))
assert calls == 4
def test_stream_events_enforces_total_timeout_before_connecting() -> None:
calls = 0
def handler(_request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(200, content="")
client = Client(
base_url="http://testserver",
sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
with pytest.raises(DifyAgentTimeoutError, match="exceeded its timeout"):
_ = list(client.stream_events_sync("run-1", timeout_seconds=0))
assert calls == 0
def test_stream_events_observes_caller_stop_on_heartbeat() -> None:
stop_checks = 0
def should_stop() -> bool:
nonlocal stop_checks
stop_checks += 1
return stop_checks >= 2
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=": keepalive\n\n")
client = Client(
base_url="http://testserver",
sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
with pytest.raises(DifyAgentStreamError, match="cancelled by the caller"):
_ = list(client.stream_events_sync("run-1", should_stop=should_stop))
assert stop_checks == 2
def test_malformed_sse_frame_does_not_reconnect() -> None:
calls = 0
@@ -34,6 +34,7 @@ def test_shell_layer_config_defaults_and_forbids_unknown_fields() -> None:
"env": [],
"secret_refs": [],
"sandbox": None,
"redact_patterns": [],
}
with pytest.raises(ValidationError):
@@ -1378,3 +1378,161 @@ def test_resource_context_reraises_non_expired_attach_error() -> None:
with pytest.raises(ShellProviderError, match="some other error"):
asyncio.run(scenario())
# ---------------------------------------------------------------------------
# Output redaction tests
# ---------------------------------------------------------------------------
def _layer_with_redaction(
*,
commands: FakeCommands,
config: DifyShellLayerConfig | None = None,
shell_redact_patterns: list[str] | None = None,
token_value: str = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.fake-long-jwe-token-value",
) -> tuple[DifyShellLayer, FakeProvider]:
"""Create a layer with agent_stub env injection and optional redaction patterns."""
provider = FakeProvider(resource=FakeResource(commands=commands))
layer = DifyShellLayer.from_config_with_settings(
config or DifyShellLayerConfig(),
shell_provider=provider,
shell_home_root="/home",
shell_redact_patterns=shell_redact_patterns,
agent_stub_api_base_url="http://localhost:5050/agent-stub",
agent_stub_token_factory=lambda execution_context, session_id: token_value,
)
return layer, provider
def test_redact_output_replaces_jwe_token_value() -> None:
"""The JWE token value should always be redacted from shell output."""
token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.super-secret-token-12345"
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output=f"DIFY_AGENT_STUB_AUTH_JWE={token}\n",
offset=100,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=100),
)
layer, _provider = _layer_with_redaction(commands=commands, token_value=token)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "env"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert token not in output
assert "***" in output
asyncio.run(scenario())
def test_redact_output_applies_server_level_patterns() -> None:
"""Server-level regex patterns from env var should redact matching content."""
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output="api_key=sk-proj-abc123xyz\n",
offset=30,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=30),
)
layer, _provider = _layer_with_redaction(
commands=commands,
shell_redact_patterns=[r"sk-proj-[A-Za-z0-9]+"],
)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "cat .env"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert "sk-proj-abc123xyz" not in output
assert "api_key=***" in output
asyncio.run(scenario())
def test_redact_output_applies_per_agent_config_patterns() -> None:
"""Per-agent redact_patterns from DifyShellLayerConfig should also apply."""
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output="token: ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890\n",
offset=50,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=50),
)
config = DifyShellLayerConfig(redact_patterns=[r"ghp_[A-Za-z0-9]{36}"])
layer, _provider = _layer_with_redaction(commands=commands, config=config)
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "echo $TOKEN"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert "ghp_" not in output
assert "token: ***" in output
asyncio.run(scenario())
def test_redact_output_skips_short_jwe_values() -> None:
"""JWE values ≤8 chars should not be redacted to avoid false positives."""
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
return _command_result(
"job-1",
status="exited",
done=True,
exit_code=0,
output="short\n",
offset=6,
)
commands = FakeCommands(
run_handler=run_handler,
tail_handler=lambda _: _command_result("job-1", done=True, exit_code=0, status="exited", offset=6),
)
# Token value is short — should NOT be redacted even if it appears in output.
layer, _provider = _layer_with_redaction(commands=commands, token_value="short")
_bind_execution_context(layer)
layer.runtime_state = _runtime_state()
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
result = await tools["shell_run"].function_schema.call({"script": "echo hi"}, None) # pyright: ignore[reportArgumentType]
_, output = _parse_tagged_observation(result)
assert "short" in output
asyncio.run(scenario())
@@ -11,13 +11,14 @@ from agenton_collections.layers.plain import PromptLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
CancelRunRequest,
CreateRunRequest,
RunComposition,
RunEvent,
RunLayerSpec,
RunStatus,
)
from dify_agent.runtime.run_scheduler import RunScheduler, SchedulerStoppingError
from dify_agent.runtime.run_scheduler import RunCancellationConflictError, RunScheduler, SchedulerStoppingError
from dify_agent.server.schemas import RunRecord
@@ -78,6 +79,11 @@ class FakeStore:
self.events[event.run_id].append(event.model_copy(update={"id": event_id}))
return event_id
async def get_run(self, run_id: str) -> RunRecord:
return self.records[run_id].model_copy(
update={"status": self.statuses[run_id], "error": self.errors.get(run_id)},
)
async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None:
self.statuses[run_id] = status
self.errors[run_id] = error
@@ -111,6 +117,23 @@ class ControlledRunner:
await self.release.wait()
class SwallowOneCancellationRunner:
started: asyncio.Event
first_cancellation: asyncio.Event
def __init__(self, *, started: asyncio.Event, first_cancellation: asyncio.Event) -> None:
self.started = started
self.first_cancellation = first_cancellation
async def run(self) -> None:
_ = self.started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
_ = self.first_cancellation.set()
await asyncio.Event().wait()
def test_create_run_starts_background_task_and_returns_running() -> None:
async def scenario() -> None:
store = FakeStore()
@@ -163,6 +186,85 @@ def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None:
asyncio.run(scenario())
def test_cancel_run_stops_task_and_persists_cancelled_terminal() -> None:
async def scenario() -> None:
store = FakeStore()
started = asyncio.Event()
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(
store=store,
plugin_daemon_http_client=client,
dify_api_http_client=client,
runner_factory=lambda _record, _request: ControlledRunner(started=started, release=asyncio.Event()),
)
record = await scheduler.create_run(_request())
await asyncio.wait_for(started.wait(), timeout=1)
response = await scheduler.cancel_run(
record.run_id,
CancelRunRequest(reason="workflow_aborted", message="outer workflow stopped"),
)
assert response.status == "cancelled"
assert scheduler.active_tasks == {}
assert store.statuses[record.run_id] == "cancelled"
assert store.errors[record.run_id] == "outer workflow stopped"
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
repeated = await scheduler.cancel_run(record.run_id, CancelRunRequest(reason="duplicate"))
assert repeated.status == "cancelled"
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
asyncio.run(scenario())
def test_cancel_run_reinjects_cancellation_without_waiting_for_runner_cleanup() -> None:
async def scenario() -> None:
store = FakeStore()
started = asyncio.Event()
first_cancellation = asyncio.Event()
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(
store=store,
plugin_daemon_http_client=client,
dify_api_http_client=client,
runner_factory=lambda _record, _request: SwallowOneCancellationRunner(
started=started,
first_cancellation=first_cancellation,
),
)
record = await scheduler.create_run(_request())
await asyncio.wait_for(started.wait(), timeout=1)
response = await asyncio.wait_for(
scheduler.cancel_run(record.run_id, CancelRunRequest(reason="workflow_aborted")),
timeout=1,
)
assert response.status == "cancelled"
assert first_cancellation.is_set()
assert store.statuses[record.run_id] == "cancelled"
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
await asyncio.sleep(0)
assert scheduler.active_tasks == {}
asyncio.run(scenario())
def test_cancel_run_rejects_finished_run() -> None:
async def scenario() -> None:
store = FakeStore()
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client)
record = await store.create_run()
await store.update_status(record.run_id, "succeeded")
with pytest.raises(RunCancellationConflictError, match="already finished"):
await scheduler.cancel_run(record.run_id, CancelRunRequest())
asyncio.run(scenario())
def test_create_run_accepts_blank_prompt_and_runner_fails_asynchronously() -> None:
async def scenario() -> None:
store = FakeStore()
@@ -64,7 +64,12 @@ from dify_agent.protocol.schemas import (
)
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError, _run_failed_error_payload
from dify_agent.runtime.runner import (
AgentRunRunner,
AgentRunValidationError,
RunSuccessOutcome,
_run_failed_error_payload,
)
from shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
@@ -164,6 +169,38 @@ def test_run_failed_error_payload_preserves_knowledge_error_code() -> None:
assert reason == "dataset_not_found"
def test_cancelled_runner_does_not_overwrite_cancelled_status_with_late_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def scenario() -> None:
sink = InMemoryRunEventSink()
cancelled = False
async with httpx.AsyncClient() as client:
runner = AgentRunRunner(
sink=sink,
request=_request(),
run_id="run-cancelled",
plugin_daemon_http_client=client,
dify_api_http_client=client,
is_cancelled=lambda: cancelled,
)
async def fail_after_cancel() -> RunSuccessOutcome:
nonlocal cancelled
cancelled = True
await sink.update_status("run-cancelled", "cancelled", "workflow stopped")
raise RuntimeError("late model failure")
monkeypatch.setattr(runner, "_run_agent", fail_after_cancel)
await runner.run()
assert sink.statuses["run-cancelled"] == "cancelled"
assert sink.errors["run-cancelled"] == "workflow stopped"
assert [event.type for event in sink.events["run-cancelled"]] == ["run_started"]
asyncio.run(scenario())
def _request(
user: str | list[str] = "hello",
*,
@@ -1,7 +1,7 @@
from fastapi.testclient import TestClient
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID
from dify_agent.runtime.run_scheduler import SchedulerStoppingError
from dify_agent.protocol import CancelRunResponse, DIFY_AGENT_MODEL_LAYER_ID
from dify_agent.runtime.run_scheduler import RunCancellationConflictError, SchedulerStoppingError
from dify_agent.server.routes.runs import create_runs_router
from dify_agent.server.schemas import RunRecord
@@ -11,6 +11,10 @@ class FakeScheduler:
del request
return RunRecord(run_id="run-1", status="running")
async def cancel_run(self, run_id: str, request: object) -> CancelRunResponse:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
class FakeStore:
pass
@@ -67,7 +71,7 @@ def test_create_run_returns_running_from_scheduler() -> None:
assert response.json() == {"run_id": "run-1", "status": "running"}
def test_cancel_run_endpoint_is_reserved_but_not_implemented() -> None:
def test_cancel_run_endpoint_returns_scheduler_result() -> None:
from fastapi import FastAPI
app = FastAPI()
@@ -78,8 +82,28 @@ def test_cancel_run_endpoint_is_reserved_but_not_implemented() -> None:
response = client.post("/runs/run-1/cancel", json={"reason": "user_cancelled"})
assert response.status_code == 501
assert response.json()["detail"] == "run cancellation is not implemented"
assert response.status_code == 200
assert response.json() == {"run_id": "run-1", "status": "cancelled"}
def test_cancel_run_endpoint_maps_conflict() -> None:
from fastapi import FastAPI
class ConflictingScheduler(FakeScheduler):
async def cancel_run(self, run_id: str, request: object) -> CancelRunResponse:
del run_id, request
raise RunCancellationConflictError("run already finished with status 'succeeded'")
app = FastAPI()
app.include_router(
create_runs_router(lambda: FakeStore(), lambda: ConflictingScheduler()) # pyright: ignore[reportArgumentType]
)
client = TestClient(app)
response = client.post("/runs/run-1/cancel", json={})
assert response.status_code == 409
assert "already finished" in response.json()["detail"]
def test_create_run_accepts_valid_full_plugin_graph() -> None:
@@ -306,3 +306,40 @@ def test_build_shell_provider_returns_none_when_enterprise_endpoint_is_unset(
def test_build_shell_provider_rejects_blank_shellctl_entrypoint() -> None:
with pytest.raises(ValidationError, match="shellctl_entrypoint is required"):
_ = ServerSettings(shell_provider="shellctl", shellctl_entrypoint=" ").build_shell_provider()
def test_server_settings_parses_shell_redact_patterns_json_array(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", '["sk-[A-Za-z0-9]+","ghp_[A-Za-z0-9]{36}"]')
settings = ServerSettings()
assert settings.get_shell_redact_patterns() == ["sk-[A-Za-z0-9]+", "ghp_[A-Za-z0-9]{36}"]
def test_server_settings_shell_redact_patterns_empty_string_yields_empty_list(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", "")
settings = ServerSettings()
assert settings.get_shell_redact_patterns() == []
def test_server_settings_shell_redact_patterns_defaults_to_empty_list(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", raising=False)
monkeypatch.chdir(tmp_path)
settings = ServerSettings()
assert settings.get_shell_redact_patterns() == []
def test_server_settings_rejects_non_array_shell_redact_patterns(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELL_REDACT_PATTERNS", '{"key": "value"}')
settings = ServerSettings()
with pytest.raises(ValueError, match="must be a JSON array"):
settings.get_shell_redact_patterns()
@@ -1,7 +1,10 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import cast
from dify_agent.protocol.schemas import RunFailedEvent, RunFailedEventData, RunStartedEvent
from dify_agent.server.sse import format_sse_event
from dify_agent.server.sse import format_sse_event, sse_event_stream
def test_format_sse_event_uses_id_event_and_json_data() -> None:
@@ -28,3 +31,21 @@ def test_format_sse_event_escapes_unicode_line_separators() -> None:
assert "\\u2028" in frame
assert "\\u2029" in frame
assert json.loads(data)["data"]["error"] == error
def test_sse_event_stream_emits_heartbeats_while_waiting() -> None:
async def scenario() -> None:
release = asyncio.Event()
async def events():
await release.wait()
yield RunStartedEvent(id="1-0", run_id="run-1")
stream = cast(AsyncGenerator[str, None], sse_event_stream(events(), heartbeat_interval_seconds=0.001))
assert await anext(stream) == ": keepalive\n\n"
_ = release.set()
assert (await anext(stream)).startswith("id: 1-0\nevent: run_started")
await stream.aclose()
asyncio.run(scenario())
+1 -1
View File
@@ -581,7 +581,7 @@ wheels = [
[[package]]
name = "dify-agent"
version = "1.16.0rc1"
version = "1.16.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
+3
View File
@@ -252,6 +252,9 @@ MARKETPLACE_URL=
# Dify Agent backend
AGENT_BACKEND_BASE_URL=http://agent_backend:5050
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
# Leave empty to derive from REDIS_PASSWORD.
DIFY_AGENT_REDIS_URL=
DIFY_AGENT_REDIS_PREFIX=dify-agent
+13 -7
View File
@@ -220,7 +220,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -232,6 +232,9 @@ services:
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
depends_on:
init_permissions:
condition: service_completed_successfully
@@ -267,7 +270,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
profiles:
- collaboration
environment:
@@ -293,7 +296,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -302,6 +305,9 @@ services:
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
depends_on:
init_permissions:
condition: service_completed_successfully
@@ -339,7 +345,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
environment:
MODE: beat
depends_on:
@@ -372,7 +378,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.16.0-rc1
image: langgenius/dify-web:1.16.0
restart: always
env_file:
- path: ./envs/core-services/web.env
@@ -525,7 +531,7 @@ services:
# Local sandbox for Dify Agent shell workspaces.
local_sandbox:
image: langgenius/dify-agent-local-sandbox:1.16.0-rc1
image: langgenius/dify-agent-local-sandbox:1.16.0
restart: always
env_file:
- path: ./envs/core-services/local-sandbox.env
@@ -630,7 +636,7 @@ services:
# Dify Agent backend service.
agent_backend:
image: langgenius/dify-agent-backend:1.16.0-rc1
image: langgenius/dify-agent-backend:1.16.0
restart: always
env_file:
- path: ./envs/core-services/dify-agent.env
+13 -7
View File
@@ -226,7 +226,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -238,6 +238,9 @@ services:
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
depends_on:
init_permissions:
condition: service_completed_successfully
@@ -273,7 +276,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
profiles:
- collaboration
environment:
@@ -299,7 +302,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -308,6 +311,9 @@ services:
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
depends_on:
init_permissions:
condition: service_completed_successfully
@@ -345,7 +351,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.16.0-rc1
image: langgenius/dify-api:1.16.0
environment:
MODE: beat
depends_on:
@@ -378,7 +384,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.16.0-rc1
image: langgenius/dify-web:1.16.0
restart: always
env_file:
- path: ./envs/core-services/web.env
@@ -531,7 +537,7 @@ services:
# Local sandbox for Dify Agent shell workspaces.
local_sandbox:
image: langgenius/dify-agent-local-sandbox:1.16.0-rc1
image: langgenius/dify-agent-local-sandbox:1.16.0
restart: always
env_file:
- path: ./envs/core-services/local-sandbox.env
@@ -636,7 +642,7 @@ services:
# Dify Agent backend service.
agent_backend:
image: langgenius/dify-agent-backend:1.16.0-rc1
image: langgenius/dify-agent-backend:1.16.0
restart: always
env_file:
- path: ./envs/core-services/dify-agent.env
@@ -3,6 +3,9 @@
# ------------------------------
AGENT_BACKEND_BASE_URL=http://agent_backend:5050
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
# Leave empty to derive from REDIS_PASSWORD in Docker Compose.
DIFY_AGENT_REDIS_URL=
@@ -26,3 +29,8 @@ 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
# JSON array of regex patterns to redact from shell output shown to the agent.
# The JWE token value is always redacted regardless of this setting.
# Example: DIFY_AGENT_SHELL_REDACT_PATTERNS=["sk-[A-Za-z0-9]+","ghp_[A-Za-z0-9]{36}"]
DIFY_AGENT_SHELL_REDACT_PATTERNS=
-230
View File
@@ -102,16 +102,6 @@
"count": 1
}
},
"web/__tests__/document-detail-navigation-fix.test.tsx": {
"no-console": {
"count": 10
}
},
"web/__tests__/document-list-sorting.test.tsx": {
"typescript/no-explicit-any": {
"count": 3
}
},
"web/__tests__/embedded-user-id-auth.test.tsx": {
"typescript/no-explicit-any": {
"count": 8
@@ -127,32 +117,6 @@
"count": 3
}
},
"web/__tests__/plugin-tool-workflow-error.test.tsx": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/__tests__/real-browser-flicker.test.tsx": {
"eslint-react/set-state-in-effect": {
"count": 4
},
"no-console": {
"count": 16
},
"typescript/no-explicit-any": {
"count": 1
}
},
"web/__tests__/unified-tags-logic.test.ts": {
"typescript/no-explicit-any": {
"count": 8
}
},
"web/__tests__/workflow-onboarding-integration.test.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -161,14 +125,6 @@
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
"no-console": {
"count": 19
},
"typescript/no-explicit-any": {
"count": 3
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx": {
"no-restricted-imports": {
"count": 1
@@ -310,11 +266,6 @@
"count": 1
}
},
"web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx": {
"jsx_a11y/anchor-is-valid": {
"count": 1
}
},
"web/app/components/app-sidebar/app-info/app-info-modals.tsx": {
"jsx_a11y/label-has-associated-control": {
"count": 1
@@ -618,22 +569,6 @@
"count": 2
}
},
"web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -866,11 +801,6 @@
"count": 2
}
},
"web/app/components/apps/__tests__/app-card.spec.tsx": {
"prefer-promise-reject-errors": {
"count": 1
}
},
"web/app/components/apps/app-card.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -960,11 +890,6 @@
"count": 2
}
},
"web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx": {
"jsx_a11y/no-autofocus": {
"count": 2
}
},
"web/app/components/base/auto-height-textarea/index.stories.tsx": {
"no-console": {
"count": 2
@@ -1818,11 +1743,6 @@
"count": 1
}
},
"web/app/components/base/input/index.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/app/components/base/logo/dify-logo.tsx": {
"react/only-export-components": {
"count": 2
@@ -2040,14 +1960,6 @@
"count": 2
}
},
"web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/current-block/component.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -2069,14 +1981,6 @@
"count": 2
}
},
"web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -2135,14 +2039,6 @@
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -2384,22 +2280,6 @@
"count": 2
}
},
"web/app/components/datasets/common/image-list/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/common/image-list/__tests__/more.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/common/image-uploader/__tests__/store.spec.tsx": {
"eslint-react/error-boundaries": {
"count": 1
@@ -2692,11 +2572,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -2820,14 +2695,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx": {
"eslint-react/static-components": {
"count": 2
@@ -2857,14 +2724,6 @@
"count": 2
}
},
"web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3492,17 +3351,6 @@
"count": 1
}
},
"web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-autofocus": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/plugins/marketplace/search-box/index.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
@@ -3598,14 +3446,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx": {
"typescript/no-explicit-any": {
"count": 3
@@ -3659,14 +3499,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": {
"erasable-syntax-only/enums": {
"count": 1
@@ -3790,14 +3622,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -3889,14 +3713,6 @@
"count": 1
}
},
"web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/rag-pipeline/components/panel/input-field/hooks.ts": {
"eslint-react/set-state-in-effect": {
"count": 1
@@ -6061,9 +5877,6 @@
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
},
"no-useless-return": {
"count": 1
}
},
"web/app/components/workflow/panel/version-history-panel/version-history-item.tsx": {
@@ -6669,31 +6482,11 @@
"count": 1
}
},
"web/service/__tests__/use-tools.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-app-access-control.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-member-roles.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-permission-catalog.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/use-app-access-control.ts": {
"no-restricted-imports": {
"count": 1
@@ -6951,11 +6744,6 @@
"count": 1
}
},
"web/service/utils.spec.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/service/webapp-auth.ts": {
"no-restricted-globals": {
"count": 6
@@ -7007,24 +6795,11 @@
"count": 1
}
},
"web/utils/get-icon.spec.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/utils/gtag.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/utils/index.spec.ts": {
"typescript/no-explicit-any": {
"count": 8
},
"vitest/no-identical-title": {
"count": 2
}
},
"web/utils/index.ts": {
"typescript/no-explicit-any": {
"count": 3
@@ -7035,11 +6810,6 @@
"count": 1
}
},
"web/utils/tool-call.spec.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/utils/validators.ts": {
"typescript/no-explicit-any": {
"count": 2
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"check": "vp fmt --check && vp lint --quiet && pnpm lint:eslint",
"check": "vp check && pnpm lint:eslint",
"check:fix": "pnpm lint:eslint:fix && vp check --fix",
"dev": "concurrently -k -n vinext,proxy \"vp run dify-web#dev:vinext\" \"vp run dify-web#dev:proxy\"",
"prepare": "vp config",
+1077 -962
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -45,7 +45,7 @@ overrides:
solid-js: 1.9.13
string-width: ~8.2.1
tar@<=7.5.15: ^7.5.16
vite: npm:@voidzero-dev/vite-plus-core@0.2.4
vite: npm:@voidzero-dev/vite-plus-core@0.2.5
ws@>=8.0.0 <8.20.1: ^8.21.0
yaml@>=2.0.0 <2.8.3: 2.9.0
yauzl@<3.2.1: 3.2.1
@@ -251,9 +251,9 @@ catalog:
use-context-selector: 2.0.0
uuid: 14.0.1
vinext: 1.0.0-beta.1
vite: npm:@voidzero-dev/vite-plus-core@0.2.4
vite: npm:@voidzero-dev/vite-plus-core@0.2.5
vite-plugin-inspect: 12.0.0-beta.3
vite-plus: 0.2.4
vite-plus: 0.2.5
vitest: 4.1.10
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
@@ -1,225 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import DatasetInfo from '@/app/components/app-sidebar/dataset-info'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { DatasetACLPermission } from '@/utils/permission'
const mockReplace = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockInvalidDatasetDetail = vi.fn()
const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const mockDownloadBlob = vi.fn()
let mockDataset: DataSet
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) =>
selector({
dataset: mockDataset,
}),
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'indexing-technique',
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidDatasetDetail,
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipeline,
}),
}))
vi.mock('@/service/datasets', () => ({
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
default: ({
show,
onClose,
onSuccess,
}: {
show: boolean
onClose: () => void
onSuccess: () => void
}) =>
show ? (
<div data-testid="rename-dataset-modal">
<button type="button" onClick={onSuccess}>
rename-success
</button>
<button type="button" onClick={onClose}>
rename-close
</button>
</div>
) : null,
}))
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
pipeline_id: 'pipeline-1',
enable_api: false,
is_multimodal: false,
is_published: true,
permission_keys: [
DatasetACLPermission.Edit,
DatasetACLPermission.Delete,
DatasetACLPermission.ImportExportDSL,
],
...overrides,
})
const openDropdown = () => {
fireEvent.click(screen.getByRole('button'))
}
describe('App Sidebar Dataset Info Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset()
mockExportPipeline.mockResolvedValue({ data: 'pipeline: demo' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
})
it('exports the published pipeline from the dropdown menu', async () => {
render(<DatasetInfo expand />)
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
openDropdown()
fireEvent.click(await screen.findByText('datasetPipeline.operations.exportPipeline'))
await waitFor(() => {
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: false,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'Dataset Name.pipeline',
}),
)
})
})
it('opens the rename modal and refreshes dataset caches after a successful rename', async () => {
render(<DatasetInfo expand />)
openDropdown()
fireEvent.click(await screen.findByText('common.operation.edit'))
expect(await screen.findByTestId('rename-dataset-modal')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'rename-success' }))
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1)
})
it('checks app usage before deleting and redirects back to the dataset list after confirmation', async () => {
render(<DatasetInfo expand />)
openDropdown()
fireEvent.click(await screen.findByText('common.operation.delete'))
await waitFor(() => {
expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1')
expect(screen.getByText('dataset.deleteDatasetConfirmTitle')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
expect(mockInvalidDatasetList).toHaveBeenCalled()
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
})
@@ -1,151 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockFetchAppDetail = vi.fn()
const mockSetAppDetail = vi.fn()
const mockRefetch = vi.fn()
let mockAppDetail: {
id: string
name: string
mode: AppModeEnum
access_mode: AccessMode
description: string
icon: string
icon_type: string
icon_background: string
site: {
app_base_url: string
access_token: string
}
} | null = null
const renderWithQueryClient = (ui: React.ReactElement) =>
renderWithSystemFeatures(ui, {
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (value: number) => `ago:${value}`,
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
isLoading: false,
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: vi.fn(() => null),
},
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppPublishUpdate: vi.fn(() => vi.fn()),
},
}))
vi.mock('@/app/components/app/app-access-control', () => ({
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>; onClose: () => void }) => (
<div data-testid="access-control-modal">
<button type="button" onClick={() => void onConfirm()}>
confirm-access-control
</button>
<button type="button" onClick={onClose}>
close-access-control
</button>
</div>
),
}))
describe('App Access Control Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
description: 'Demo app description',
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
},
}
mockFetchAppDetail.mockResolvedValue({
...mockAppDetail,
access_mode: AccessMode.PUBLIC,
})
})
it('refreshes app detail after confirming access control updates', async () => {
const { queryClient } = renderWithQueryClient(<AppPublisher publishedAt={1700000000} />)
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' }))
fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific'))
expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' }))
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(
['apps', 'detail', 'app-1'],
expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}),
)
expect(mockSetAppDetail).toHaveBeenCalledWith(
expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}),
)
await waitFor(() => {
expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()
})
})
})
@@ -1,210 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AppPublisher } from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockTrackEvent = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockSetAppDetail = vi.fn()
let mockAppDetail: {
id: string
name: string
mode: AppModeEnum
access_mode: AccessMode
description: string
icon: string
icon_type: string
icon_background: string
site: {
app_base_url: string
access_token: string
}
} | null = null
const renderWithQueryClient = (ui: React.ReactElement) =>
renderWithSystemFeatures(ui, {
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (value: number) => `ago:${value}`,
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
}),
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
isLoading: false,
}),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) =>
isShow ? (
<div data-testid="embedded-modal">
<button onClick={onClose}>close-embedded</button>
</div>
) : null,
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: {
getSocket: vi.fn(() => null),
},
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppPublishUpdate: vi.fn(() => vi.fn()),
},
}))
vi.mock('@/app/components/app/app-access-control', () => {
const MockAccessControl = () => <div data-testid="app-access-control" />
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
describe('App Publisher Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
description: 'Demo app description',
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
},
}
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetailDirect.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockOpenAsyncWindow.mockImplementation(
async (resolver: () => Promise<string>, options?: { onError?: (error: Error) => void }) => {
try {
return await resolver()
} catch (error) {
options?.onError?.(error as Error)
}
},
)
})
it('publishes from the summary panel and tracks the publish event', async () => {
const onPublish = vi.fn().mockResolvedValue(undefined)
renderWithQueryClient(<AppPublisher publishedAt={1700000000} onPublish={onPublish} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(screen.getByText(/(?:^|\.)common\.latestPublished(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument()
fireEvent.click(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/))
await waitFor(() => {
expect(onPublish).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith(
'app_published_time',
expect.objectContaining({
action_mode: 'app',
app_id: 'app-1',
app_name: 'Demo App',
}),
)
})
})
it('opens embedded modal and resolves the installed explore target', async () => {
renderWithQueryClient(<AppPublisher publishedAt={1700000000} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/))
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
await waitFor(() => {
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
})
})
it('shows a toast error when no installed explore app is available', async () => {
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [],
})
renderWithQueryClient(<AppPublisher publishedAt={1700000000} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/),
)
})
})
})
@@ -1,426 +0,0 @@
/**
* Integration test: App Card Operations Flow
*
* Tests the end-to-end user flows for app card operations:
* - Editing app info
* - Duplicating an app
* - Deleting an app
* - Exporting app DSL
* - Navigation on card click
* - Access mode icons
*/
import type { App } from '@/types/app'
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'
import { AccessMode } from '@/models/access-control'
import { exportAppConfig, updateAppInfo } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}))
const mockRouterPush = vi.fn()
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) =>
toastMocks.mockNotify({ type: 'info', message, ...options }),
dismiss: toastMocks.dismiss,
update: toastMocks.update,
promise: toastMocks.promise,
},
}))
const mockOnPlanInfoChanged = vi.fn()
const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined)
let mockDeleteMutationPending = false
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({
data: [],
}),
}
})
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<React.ComponentType | { default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
loader()
.then((mod) => {
Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType<
Record<string, unknown>
>
})
.catch(() => {})
const Wrapper = (props: Record<string, unknown>) => {
if (Component) return <Component {...props} />
return null
}
Wrapper.displayName = 'DynamicWrapper'
return Wrapper
},
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-apps', () => ({
useDeleteAppMutation: () => ({
mutateAsync: mockDeleteAppMutation,
isPending: mockDeleteMutationPending,
}),
useToggleAppStarMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/apps', () => ({
deleteApp: vi.fn().mockResolvedValue({}),
updateAppInfo: vi.fn().mockResolvedValue({}),
copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }),
exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// Mock modals loaded via next/dynamic
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="edit-app-modal">
<span data-testid="modal-app-name">{appName as string}</span>
<button
data-testid="confirm-edit"
onClick={() =>
(onConfirm as (data: Record<string, unknown>) => void)({
name: 'Updated App Name',
icon_type: 'emoji',
icon: '🔥',
icon_background: '#fff',
description: 'Updated description',
})
}
>
Confirm
</button>
<button data-testid="cancel-edit" onClick={onHide as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/duplicate-modal', () => ({
default: ({ show, onConfirm, onHide }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="duplicate-app-modal">
<button
data-testid="confirm-duplicate"
onClick={() =>
(onConfirm as (data: Record<string, unknown>) => void)({
name: 'Copied App',
icon_type: 'emoji',
icon: '📋',
icon_background: '#fff',
})
}
>
Confirm Duplicate
</button>
<button data-testid="cancel-duplicate" onClick={onHide as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/switch-app-modal', () => ({
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="switch-app-modal">
<button data-testid="confirm-switch" onClick={onSuccess as () => void}>
Confirm Switch
</button>
<button data-testid="cancel-switch" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="dsl-export-confirm-modal">
<button
data-testid="export-include"
onClick={() => (onConfirm as (include: boolean) => void)(true)}
>
Include
</button>
<button data-testid="export-close" onClick={onClose as () => void}>
Close
</button>
</div>
),
}))
vi.mock('@/app/components/app/app-access-control', () => {
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="access-control-modal">
<button data-testid="confirm-access" onClick={onConfirm as () => void}>
Confirm
</button>
<button data-testid="cancel-access" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'Test Chat App',
description: overrides.description ?? 'A chat application',
author_name: overrides.author_name ?? 'Test Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? ({} as App['model_config']),
app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']),
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? ({} as App['site']),
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
created_by: overrides.created_by ?? 'user-1',
permission_keys: overrides.permission_keys ?? [
AppACLPermission.Edit,
AppACLPermission.ImportExportDSL,
AppACLPermission.Delete,
AppACLPermission.ReleaseAndVersion,
AppACLPermission.AccessConfig,
],
})
const mockOnRefresh = vi.fn()
const renderAppCard = (app?: Partial<App>) => {
return renderWithSystemFeatures(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />, {
systemFeatures: mockSystemFeatures,
})
}
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', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDeleteMutationPending = false
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Card Rendering', () => {
it('should render app name and description', () => {
renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' })
expect(screen.getByText('My AI Bot')).toBeInTheDocument()
expect(screen.getByText('An intelligent assistant')).toBeInTheDocument()
})
it('should render author name', () => {
renderAppCard({ author_name: 'John Doe' })
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should navigate to app config page when card is clicked', () => {
renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT })
expect(screen.getByRole('link', { name: 'Test Chat App' })).toHaveAttribute(
'href',
'/app/app-123/configuration',
)
})
it('should navigate to workflow page for workflow apps', () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
expect(screen.getByRole('link', { name: 'WF App' })).toHaveAttribute(
'href',
'/app/app-wf/workflow',
)
})
})
// -- Delete flow --
describe('Delete App Flow', () => {
it('should show delete confirmation and call API on confirm', async () => {
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
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()
})
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')
})
})
})
// -- Edit flow --
describe('Edit App Flow', () => {
it('should open edit modal and call updateAppInfo on confirm', async () => {
renderAppCard({ id: 'app-edit', name: 'Editable App' })
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(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
})
})
// -- Export flow --
describe('Export App Flow', () => {
it('should call exportAppConfig for completion apps', async () => {
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
const user = await openOperationsMenu('Export App')
await user.click(await screen.findByRole('menuitem', { name: 'app.export' }))
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
})
})
// -- Access mode display --
describe('Access Mode Display', () => {
it('should not render operations menu when user has no app permissions', () => {
renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] })
expect(
screen.queryByRole('button', {
name: /common\.operation\.moreActionsFor/,
}),
).not.toBeInTheDocument()
})
})
// -- Switch mode (only for CHAT/COMPLETION) --
describe('Switch App Mode', () => {
it('should show switch option for chat mode apps', async () => {
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
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' })
await openOperationsMenu('WF App')
expect(await screen.findByRole('menu')).toBeVisible()
expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument()
})
})
})
@@ -1,538 +0,0 @@
import type { ReactElement, ReactNode } from 'react'
/**
* Integration test: App List Browsing Flow
*
* Tests the end-to-end user flow of browsing, filtering, searching,
* and tab switching in the apps list page.
*
* Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
let mockIsLoadingCurrentWorkspace = false
let mockWorkspacePermissionKeys: string[] = ['app.create_and_management']
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
let mockPages: AppListResponse[] = []
let mockIsLoading = false
let mockIsFetching = false
let mockIsFetchingNextPage = false
let mockHasNextPage = false
let mockError: Error | null = null
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
let mockShowTagManagementModal = false
const mockRouterPush = vi.fn()
const mockRouterReplace = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/next/dynamic', () => ({
default: (_loader: () => Promise<{ default: React.ComponentType }>) => {
const LazyComponent = (props: Record<string, unknown>) => {
return <div data-testid="dynamic-component" {...props} />
}
LazyComponent.displayName = 'DynamicComponent'
return LazyComponent
},
}))
vi.mock('@/context/account-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/workspace-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/permission-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/version-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/system-features-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } =
await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: vi.fn(),
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
tagList: [],
showTagManagementModal: mockShowTagManagementModal,
setTagList: vi.fn(),
setShowTagManagementModal: vi.fn(),
}
return selector(state)
},
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'member-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'member-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({
data: [],
}),
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
error: mockError,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
normalizeAppPagination: <T,>(response: T) => response,
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useToggleAppStarMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: () => ({
onlineUsersMap: {},
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: (...args: unknown[]) => fnRef.current(...args),
}
},
}
})
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'My Chat App',
description: overrides.description ?? 'A chat application',
author_name: overrides.author_name ?? 'Test Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? ({} as App['model_config']),
app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']),
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? ({} as App['site']),
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
})
const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({
data: apps,
has_more: hasMore,
limit: 30,
page,
total: apps.length,
})
const renderListUI = (ui: ReactElement, searchParams?: Record<string, string>) => {
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
systemFeatures: mockSystemFeatures,
})
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
const Wrapper = ({ children }: { children: ReactNode }) => (
<NuqsWrapper>
<SysWrapper>{children}</SysWrapper>
</NuqsWrapper>
)
return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate }
}
const renderList = (searchParams?: Record<string, string>) => {
return renderListUI(<List controlRefreshList={0} />, searchParams)
}
describe('App List Browsing Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsLoadingCurrentWorkspace = false
mockWorkspacePermissionKeys = ['app.create_and_management']
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
mockPages = []
mockIsLoading = false
mockIsFetching = false
mockIsFetchingNextPage = false
mockHasNextPage = false
mockError = null
mockShowTagManagementModal = false
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Loading and Empty States', () => {
it('should show skeleton cards during initial loading', () => {
mockIsLoading = true
renderList()
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
})
it('should show empty state when no apps exist', () => {
mockPages = [createPage([])]
renderList()
expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument()
})
it('should transition from loading to content when data loads', () => {
mockIsLoading = true
const { rerender } = renderListUI(<List controlRefreshList={0} />)
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
// Data loads
mockIsLoading = false
mockPages = [createPage([createMockApp({ id: 'app-1', name: 'Loaded App' })])]
rerender(<List controlRefreshList={0} />)
expect(screen.getByText('Loaded App')).toBeInTheDocument()
})
})
// -- Rendering apps --
describe('App List Rendering', () => {
it('should render all app cards from the data', () => {
mockPages = [
createPage([
createMockApp({ id: 'app-1', name: 'Chat Bot' }),
createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }),
createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }),
]),
]
renderList()
expect(screen.getByText('Chat Bot')).toBeInTheDocument()
expect(screen.getByText('Workflow Engine')).toBeInTheDocument()
expect(screen.getByText('Completion Tool')).toBeInTheDocument()
})
it('should display app descriptions', () => {
mockPages = [
createPage([createMockApp({ name: 'My App', description: 'A powerful AI assistant' })]),
]
renderList()
expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument()
})
it('should show the create menu for workspace editors', () => {
mockPages = [createPage([createMockApp({ name: 'Test App' })])]
renderList()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
})
it('should hide the create menu when user lacks app creation permission', () => {
mockWorkspacePermissionKeys = []
mockPages = [createPage([createMockApp({ name: 'Test App' })])]
renderList()
expect(
screen.queryByRole('button', { name: 'common.operation.create' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'app.newApp.startFromBlank' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'app.newApp.startFromTemplate' }),
).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'app.importDSL' })).not.toBeInTheDocument()
})
})
// -- Legacy footer removal --
describe('Legacy Footer', () => {
it('should not show the legacy footer when branding is disabled', () => {
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } }
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.join')).not.toBeInTheDocument()
expect(screen.queryByText('app.communityIntro')).not.toBeInTheDocument()
})
it('should hide footer when branding is enabled', () => {
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } }
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.join')).not.toBeInTheDocument()
})
})
// -- DSL drag-drop hint --
describe('DSL Drag-Drop Hint', () => {
it('should show drag-drop hint for workspace editors', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should hide drag-drop hint without app creation permission', () => {
mockWorkspacePermissionKeys = []
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
})
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render all category options', async () => {
mockPages = [createPage([createMockApp()])]
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.types' }))
expect(
await screen.findByRole('menuitemradio', { name: 'app.types.all' }),
).toBeInTheDocument()
expect(
await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }),
).toBeInTheDocument()
expect(
await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }),
).toBeInTheDocument()
expect(
await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }),
).toBeInTheDocument()
expect(
await screen.findByRole('menuitemradio', { name: 'app.types.agent' }),
).toBeInTheDocument()
expect(
await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }),
).toBeInTheDocument()
})
})
// -- Search --
describe('Search Filtering', () => {
it('should render search input', () => {
mockPages = [createPage([createMockApp()])]
renderList()
const input = document.querySelector('input')
expect(input).toBeInTheDocument()
})
it('should update search query when typing in search input', async () => {
mockPages = [createPage([createMockApp()])]
const { onUrlUpdate } = renderList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'test search' } })
await waitFor(() => {
expect(onUrlUpdate).toHaveBeenCalled()
})
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(lastCall.searchParams.get('keywords')).toBe('test search')
})
})
// -- Creators filter --
describe('Creators Filter', () => {
it('should render the creators filter', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
})
it('should open the creators filter menu', () => {
mockPages = [createPage([createMockApp()])]
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.creators' }))
expect(screen.getByRole('button', { name: /Bob/ })).toBeInTheDocument()
})
})
// -- Fetching next page skeleton --
describe('Pagination Loading', () => {
it('should show skeleton when fetching next page', () => {
mockPages = [createPage([createMockApp()])]
mockIsFetchingNextPage = true
renderList()
const skeletonCards = document.querySelectorAll('.animate-pulse')
expect(skeletonCards.length).toBeGreaterThan(0)
})
})
// -- Dataset operator behavior --
describe('Dataset Operator Behavior', () => {
it('should not redirect at list component level for dataset operators', () => {
renderList()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
})
// -- Multiple pages of data --
describe('Multi-page Data', () => {
it('should render apps from multiple pages', () => {
mockPages = [
createPage([createMockApp({ id: 'app-1', name: 'Page One App' })], true, 1),
createPage([createMockApp({ id: 'app-2', name: 'Page Two App' })], false, 2),
]
renderList()
expect(screen.getByText('Page One App')).toBeInTheDocument()
expect(screen.getByText('Page Two App')).toBeInTheDocument()
})
})
// -- controlRefreshList triggers refetch --
describe('Refresh List', () => {
it('should call refetch when controlRefreshList increments', () => {
mockPages = [createPage([createMockApp()])]
const { rerender } = renderListUI(<List controlRefreshList={0} />)
rerender(<List controlRefreshList={1} />)
expect(mockRefetch).toHaveBeenCalled()
})
})
})
-569
View File
@@ -1,569 +0,0 @@
import type { ReactNode } from 'react'
/**
* Integration test: Create App Flow
*
* Tests the end-to-end user flows for creating new apps:
* - Creating from blank via NewAppCard
* - Creating from template via NewAppCard
* - Creating from DSL import via NewAppCard
* - Apps page top-level state management
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import List from '@/app/components/apps/list'
import { AccessMode } from '@/models/access-control'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
let mockIsLoadingCurrentWorkspace = false
let mockWorkspacePermissionKeys: string[] = ['app.create_and_management']
let mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
let mockPages: AppListResponse[] = []
let mockIsLoading = false
let mockIsFetching = false
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
let mockShowTagManagementModal = false
const mockRouterPush = vi.fn()
const mockRouterReplace = vi.fn()
const mockOnPlanInfoChanged = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/context/account-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/workspace-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/permission-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/version-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/context/system-features-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: { id: 'user-1' },
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } =
await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
tagList: [],
showTagManagementModal: mockShowTagManagementModal,
setTagList: vi.fn(),
setShowTagManagementModal: vi.fn(),
}
return selector(state)
},
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({
data: [],
}),
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
normalizeAppPagination: <T,>(response: T) => response,
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useToggleAppStarMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: () => ({
onlineUsersMap: {},
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: (...args: unknown[]) => fnRef.current(...args),
}
},
}
})
// Mock dynamically loaded modals with test stubs
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
loader()
.then((mod) => {
Component = mod.default as React.ComponentType<Record<string, unknown>>
})
.catch(() => {})
const Wrapper = (props: Record<string, unknown>) => {
if (Component) return <Component {...props} />
return null
}
Wrapper.displayName = 'DynamicWrapper'
return Wrapper
},
}))
vi.mock('@/app/components/app/create-app-modal', () => ({
default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="create-app-modal">
<button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>
Create Blank
</button>
{!!onCreateFromTemplate && (
<button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>
From Template
</button>
)}
<button data-testid="create-blank-cancel" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-app-dialog', () => ({
default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="template-dialog">
<button data-testid="template-confirm" onClick={onSuccess as () => void}>
Create from Template
</button>
{!!onCreateFromBlank && (
<button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>
From Blank
</button>
)}
<button data-testid="template-cancel" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
if (!show) return null
return (
<div data-testid="create-from-dsl-modal">
<button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>
Import DSL
</button>
<button data-testid="dsl-import-cancel" onClick={onClose as () => void}>
Cancel
</button>
</div>
)
},
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
FROM_FILE: 'from-file',
},
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
name: overrides.name ?? 'Test App',
description: overrides.description ?? 'A test app',
author_name: overrides.author_name ?? 'Author',
icon_type: overrides.icon_type ?? 'emoji',
icon: overrides.icon ?? '🤖',
icon_background: overrides.icon_background ?? '#FFEAD5',
icon_url: overrides.icon_url ?? null,
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
mode: overrides.mode ?? AppModeEnum.CHAT,
enable_site: overrides.enable_site ?? true,
enable_api: overrides.enable_api ?? true,
api_rpm: overrides.api_rpm ?? 60,
api_rph: overrides.api_rph ?? 3600,
is_demo: overrides.is_demo ?? false,
model_config: overrides.model_config ?? ({} as App['model_config']),
app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']),
created_at: overrides.created_at ?? 1700000000,
updated_at: overrides.updated_at ?? 1700001000,
site: overrides.site ?? ({} as App['site']),
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
tags: overrides.tags ?? [],
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
max_active_requests: overrides.max_active_requests ?? null,
})
const createPage = (apps: App[]): AppListResponse => ({
data: apps,
has_more: false,
limit: 30,
page: 1,
total: apps.length,
})
const renderList = () => {
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
systemFeatures: mockSystemFeatures,
})
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper()
const Wrapper = ({ children }: { children: ReactNode }) => (
<NuqsWrapper>
<SysWrapper>{children}</SysWrapper>
</NuqsWrapper>
)
return { ...render(<List controlRefreshList={0} />, { wrapper: Wrapper }), onUrlUpdate }
}
const openCreateMenu = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
}
const clickCreateMenuItem = (label: string) => {
openCreateMenu()
fireEvent.click(screen.getByText(label))
}
describe('Create App Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsLoadingCurrentWorkspace = false
mockWorkspacePermissionKeys = ['app.create_and_management']
mockSystemFeatures = {
branding: { enabled: false },
webapp_auth: { enabled: false },
}
mockPages = [createPage([createMockApp()])]
mockIsLoading = false
mockIsFetching = false
mockShowTagManagementModal = false
})
describe('NewAppCard Rendering', () => {
it('should render the create menu with all options', () => {
renderList()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
openCreateMenu()
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
})
it('should render disabled the create menu when user lacks app creation permission', () => {
mockWorkspacePermissionKeys = []
renderList()
expect(
screen.queryByRole('button', { name: 'common.operation.create' }),
).not.toBeInTheDocument()
})
it('should keep the create menu available while workspace state is loading', () => {
mockIsLoadingCurrentWorkspace = true
renderList()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
})
})
// -- Create from blank --
describe('Create from Blank Flow', () => {
it('should open the create app modal when "Start from Blank" is clicked', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromBlank')
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
it('should close the create app modal on cancel', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromBlank')
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('create-blank-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
it('should call onPlanInfoChanged and refetch on successful creation', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromBlank')
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('create-blank-confirm'))
await waitFor(() => {
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockRefetch).toHaveBeenCalled()
})
})
})
// -- Create from template --
describe('Create from Template Flow', () => {
it('should open template dialog when "Start from Template" is clicked', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromTemplate')
await waitFor(() => {
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
})
})
it('should allow switching from template to blank modal', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromTemplate')
await waitFor(() => {
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('switch-to-blank'))
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument()
})
})
it('should allow switching from blank to template dialog', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromBlank')
await waitFor(() => {
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('switch-to-template'))
await waitFor(() => {
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
})
})
// -- Create from DSL import (via NewAppCard button) --
describe('Create from DSL Import Flow', () => {
it('should open DSL import modal when "Import DSL" is clicked', async () => {
renderList()
clickCreateMenuItem('app.importDSL')
await waitFor(() => {
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
})
})
it('should close DSL import modal on cancel', async () => {
renderList()
clickCreateMenuItem('app.importDSL')
await waitFor(() => {
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dsl-import-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument()
})
})
it('should call onPlanInfoChanged and refetch on successful DSL import', async () => {
renderList()
clickCreateMenuItem('app.importDSL')
await waitFor(() => {
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dsl-import-confirm'))
await waitFor(() => {
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockRefetch).toHaveBeenCalled()
})
})
})
// -- DSL drag-and-drop flow (via List component) --
describe('DSL Drag-Drop Flow', () => {
it('should show drag-drop hint in the list', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should open create-from-DSL modal when DSL file is dropped', async () => {
const { act } = await import('@testing-library/react')
renderList()
const container = document.querySelector('[class*="overflow-y-auto"]')
if (container) {
const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' })
// Simulate the full drag-drop sequence wrapped in act
await act(async () => {
const dragEnterEvent = new Event('dragenter', { bubbles: true })
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
value: { types: ['Files'], files: [] },
})
Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() })
Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() })
container.dispatchEvent(dragEnterEvent)
const dropEvent = new Event('drop', { bubbles: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [yamlFile], types: ['Files'] },
})
Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() })
Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() })
container.dispatchEvent(dropEvent)
})
await waitFor(() => {
const modal = screen.queryByTestId('create-from-dsl-modal')
if (modal) expect(modal).toBeInTheDocument()
})
}
})
})
// -- Edge cases --
describe('Edge Cases', () => {
it('should not show create options when no data and user is editor', () => {
mockPages = [createPage([])]
renderList()
expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
})
it('should handle multiple rapid clicks on create buttons without crashing', async () => {
renderList()
clickCreateMenuItem('app.newApp.startFromBlank')
clickCreateMenuItem('app.newApp.startFromTemplate')
clickCreateMenuItem('app.importDSL')
// Should not crash, and some modal should be present
await waitFor(() => {
const anyModal =
screen.queryByTestId('create-app-modal') ||
screen.queryByTestId('template-dialog') ||
screen.queryByTestId('create-from-dsl-modal')
expect(anyModal).toBeTruthy()
})
})
})
})
@@ -152,11 +152,14 @@ describe('Base Notion Page Selector Flow', () => {
]),
)
await user.type(screen.getByTestId('notion-search-input'), 'missing-page')
await user.type(
screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages'),
'missing-page',
)
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
expect(screen.getByText('Root 1')).toBeInTheDocument()
await user.click(screen.getByTestId('notion-page-preview-root-1'))
expect(onPreview).toHaveBeenCalledWith(
@@ -181,7 +184,7 @@ describe('Base Notion Page Selector Flow', () => {
expect(onSelectCredential).toHaveBeenCalledWith('c1')
await user.click(screen.getByRole('combobox', { name: /Workspace 1/ }))
await user.click(screen.getByTestId('notion-credential-item-c2'))
await user.click(screen.getByRole('option', { name: /Workspace 2/ }))
expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({
datasetId: 'dataset-1',
@@ -191,7 +194,7 @@ describe('Base Notion Page Selector Flow', () => {
await waitFor(() => {
expect(onSelectCredential).toHaveBeenLastCalledWith('c2')
expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument()
expect(screen.getByText('External 1')).toBeInTheDocument()
})
await user.click(
@@ -1,112 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import CustomPage from '@/app/components/custom/custom-page'
import useWebAppBrand from '@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand'
const mockSetShowPricingModal = vi.fn()
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
vi.mock('@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand', () => ({
__esModule: true,
default: vi.fn(),
}))
const { useProviderContext } = await import('@/context/provider-context')
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseWebAppBrand = vi.mocked(useWebAppBrand)
const createBrandState = (
overrides: Partial<ReturnType<typeof useWebAppBrand>> = {},
): ReturnType<typeof useWebAppBrand> => ({
fileId: '',
imgKey: 1,
uploadProgress: 0,
uploading: false,
webappLogo: 'https://example.com/logo.png',
webappBrandRemoved: false,
uploadDisabled: false,
workspaceLogo: 'https://example.com/workspace-logo.png',
canManageCustomBrand: true,
isSandbox: false,
handleApply: vi.fn(),
handleCancel: vi.fn(),
handleChange: vi.fn(),
handleRestore: vi.fn(),
handleSwitch: vi.fn(),
...overrides,
})
const setProviderPlan = (planType: Plan, enableBilling = true) => {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({
enableBilling,
plan: {
...defaultPlan,
type: planType,
},
}),
)
}
describe('Custom Page Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
setProviderPlan(Plan.professional)
mockUseWebAppBrand.mockReturnValue(createBrandState())
})
it('shows the billing upgrade banner for sandbox workspaces and opens pricing modal', () => {
setProviderPlan(Plan.sandbox)
render(<CustomPage />)
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort'))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('renders the branding controls and the sales contact footer for paid workspaces', () => {
const hookState = createBrandState({
fileId: 'pending-logo',
})
mockUseWebAppBrand.mockReturnValue(hookState)
render(<CustomPage />)
const contactLink = screen.getByText('custom.customize.contactUs').closest('a')
expect(contactLink).toHaveAttribute('href', contactSalesUrl)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'custom.restore' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'custom.apply' }))
expect(hookState.handleSwitch).toHaveBeenCalledWith(true)
expect(hookState.handleRestore).toHaveBeenCalledTimes(1)
expect(hookState.handleCancel).toHaveBeenCalledTimes(1)
expect(hookState.handleApply).toHaveBeenCalledTimes(1)
})
})
@@ -1,312 +0,0 @@
/**
* Integration Test: Create Dataset Flow
*
* Tests cross-module data flow: step-one data step-two hooks creation params API call
* Validates data contracts between steps.
*/
import type { CustomFile } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
const mockCreateFirstDocument = vi.fn()
const mockCreateDocument = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }),
useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }),
getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({
workspace_id: 'ws-1',
pages: pages.map((p) => p.page_id),
notion_credential_id: credentialId,
}),
getWebsiteInfo: (opts: { websitePages: { url: string }[]; websiteCrawlProvider: string }) => ({
urls: opts.websitePages.map((p) => p.url),
only_main_content: true,
provider: opts.websiteCrawlProvider,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
default: { notify: vi.fn() },
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Import hooks after mocks
const {
useSegmentationState,
DEFAULT_SEGMENT_IDENTIFIER,
DEFAULT_MAXIMUM_CHUNK_LENGTH,
DEFAULT_OVERLAP,
} = await import('@/app/components/datasets/create/step-two/hooks')
const { useDocumentCreation, IndexingType } =
await import('@/app/components/datasets/create/step-two/hooks')
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile =>
({
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 1024,
extension: '.txt',
mime_type: 'text/plain',
created_at: 0,
created_by: '',
...overrides,
}) as CustomFile
describe('Create Dataset Flow - Cross-Step Data Contract', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Step-One → Step-Two: Segmentation Defaults', () => {
it('should initialise with correct default segmentation values', () => {
const { result } = renderHook(() => useSegmentationState())
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
expect(result.current.segmentationType).toBe(ProcessMode.general)
})
it('should produce valid process rule for general chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.text)
// mode should be segmentationType = ProcessMode.general = 'custom'
expect(processRule.mode).toBe('custom')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n', // unescaped from \\n\\n
max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH,
chunk_overlap: DEFAULT_OVERLAP,
})
// rules is empty initially since no default config loaded
expect(processRule.rules.pre_processing_rules).toEqual([])
})
it('should produce valid process rule for parent-child chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.parentChild)
expect(processRule.mode).toBe('hierarchical')
expect(processRule.rules.parent_mode).toBe('paragraph')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n',
max_tokens: 1024,
})
expect(processRule.rules.subchunk_segmentation).toEqual({
separator: '\n',
max_tokens: 512,
})
})
})
describe('Step-Two → Creation API: Params Building', () => {
it('should build valid creation params for file upload workflow', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
const retrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
}
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'English',
processRule,
retrievalConfig,
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
// File IDs come from file.id (not file.file.id)
expect(params!.data_source.type).toBe(DataSourceType.FILE)
expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1')
expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED)
expect(params!.doc_form).toBe(ChunkingMode.text)
expect(params!.doc_language).toBe('English')
expect(params!.embedding_model).toBe('text-embedding-ada-002')
expect(params!.embedding_model_provider).toBe('openai')
expect(params!.process_rule.mode).toBe('custom')
})
it('should validate params: overlap must not exceed maxChunkLength', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// validateParams returns false (invalid) when overlap > maxChunkLength for general mode
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 100,
limitMaxChunkLength: 4000,
overlap: 200, // overlap > maxChunkLength
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
it('should validate params: maxChunkLength must not exceed limit', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 5000,
limitMaxChunkLength: 4000, // limit < maxChunkLength
overlap: 50,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
})
describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => {
it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// Change segmentation settings
act(() => {
segResult.current.setMaxChunkLength(2048)
segResult.current.setOverlap(100)
})
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
expect(processRule.rules.segmentation.max_tokens).toBe(2048)
expect(processRule.rules.segmentation.chunk_overlap).toBe(100)
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'Chinese',
processRule,
{
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048)
expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100)
expect(params!.doc_language).toBe('Chinese')
})
it('should support parent-child mode through the full pipeline', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild)
const params = creationResult.current.buildCreationParams(
ChunkingMode.parentChild,
'English',
processRule,
{
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.doc_form).toBe(ChunkingMode.parentChild)
expect(params!.process_rule.mode).toBe('hierarchical')
expect(params!.process_rule.rules.parent_mode).toBe('paragraph')
expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined()
})
})
})
@@ -1,488 +0,0 @@
/**
* Integration Test: Dataset Settings Flow
*
* Tests cross-module data contracts in the dataset settings form:
* useFormState hook index method config retrieval config permission state.
*
* The unit-level use-form-state.spec.ts validates the hook in isolation.
* This integration test verifies that changing one configuration dimension
* correctly cascades to dependent parts (index method retrieval config,
* permission member list visibility, embedding model embedding available state).
*/
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook, waitFor } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import {
ChunkingMode,
DatasetPermission,
DataSourceType,
WeightedScoreEnum,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { DatasetACLPermission } from '@/utils/permission'
// --- Mocks ---
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
const mockMutateDatasets = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
role: 'owner',
avatar: '',
avatar_url: '',
last_login_at: '',
created_at: '',
status: 'active',
},
{
id: 'user-2',
name: 'Bob',
email: 'bob@example.com',
role: 'admin',
avatar: '',
avatar_url: '',
last_login_at: '',
created_at: '',
status: 'active',
},
{
id: 'user-3',
name: 'Charlie',
email: 'charlie@example.com',
role: 'normal',
avatar: '',
avatar_url: '',
last_login_at: '',
created_at: '',
status: 'active',
},
],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mockToastError,
success: vi.fn(),
},
}))
// --- Dataset factory ---
const createMockDataset = (overrides?: Partial<DataSet>): DataSet =>
({
id: 'ds-settings-1',
name: 'Settings Test Dataset',
description: 'Integration test dataset',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
indexing_technique: 'high_quality',
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 2,
document_count: 10,
total_document_count: 10,
word_count: 5000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: false,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
permission_keys: [DatasetACLPermission.Edit],
...overrides,
}) as DataSet
let mockDataset: DataSet = createMockDataset()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (
selector: (state: { dataset: DataSet | null; mutateDatasetRes: () => void }) => unknown,
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
}))
// Import after mocks are registered
const { useFormState } =
await import('@/app/components/datasets/settings/form/hooks/use-form-state')
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUpdateDatasetSetting.mockResolvedValue({})
mockDataset = createMockDataset()
})
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.name).toBe('Settings Test Dataset')
expect(result.current.description).toBe('Integration test dataset')
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-ada-002',
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
})
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
mockDataset = createMockDataset({
indexing_technique: IndexingType.ECONOMICAL,
embedding_model: '',
embedding_model_provider: '',
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
})
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
})
})
describe('Index Method Change → Retrieval Config Sync', () => {
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe('high_quality')
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
})
it('should allow updating retrieval config after index method switch', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
})
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
})
it('should preserve retrieval config when switching back to QUALIFIED', () => {
const { result } = renderHook(() => useFormState())
const originalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setIndexMethod(IndexingType.QUALIFIED)
})
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
})
})
describe('Permission Change → Member List Visibility Logic', () => {
it('should start with onlyMe permission and empty member selection', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
expect(result.current.selectedMemberIDs).toEqual([])
})
it('should enable member selection when switching to partialMembers', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
expect(result.current.memberList).toHaveLength(3)
expect(result.current.memberList.map((m) => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
})
it('should persist member selection through permission toggle', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
})
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
})
it('should include partial_member_list in save payload only for partialMembers', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-2'])
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
permission: DatasetPermission.partialMembers,
partial_member_list: [expect.objectContaining({ user_id: 'user-2', role: 'admin' })],
}),
})
})
it('should not include partial_member_list for allTeamMembers permission', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
await act(async () => {
await result.current.handleSave()
})
const savedBody = mockUpdateDatasetSetting.mock.calls[0]![0].body as Record<string, unknown>
expect(savedBody).not.toHaveProperty('partial_member_list')
})
})
describe('Form Submission Validation → All Fields Together', () => {
it('should reject empty name on save', async () => {
const { toast } = await import('@langgenius/dify-ui/toast')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('')
})
await act(async () => {
await result.current.handleSave()
})
expect(toast.error).toHaveBeenCalledWith(expect.any(String))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('should include all configuration dimensions in a successful save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('Updated Name')
result.current.setDescription('Updated Description')
result.current.setIndexMethod(IndexingType.ECONOMICAL)
result.current.setKeywordNumber(15)
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
name: 'Updated Name',
description: 'Updated Description',
indexing_technique: 'economy',
keyword_number: 15,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
}),
})
})
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(mockMutateDatasets).toHaveBeenCalled()
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
})
describe('Embedding Model Change → Retrieval Config Cascade', () => {
it('should update embedding model independently of retrieval config', () => {
const { result } = renderHook(() => useFormState())
const originalRetrievalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
})
expect(result.current.embeddingModel).toEqual({
provider: 'cohere',
model: 'embed-english-v3.0',
})
expect(result.current.retrievalConfig.search_method).toBe(
originalRetrievalConfig.search_method,
)
})
it('should propagate embedding model into weighted retrieval config on save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.6,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: { keyword_weight: 0.4 },
},
})
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
embedding_model: 'embed-v3',
embedding_model_provider: 'cohere',
retrieval_model: expect.objectContaining({
weights: expect.objectContaining({
vector_setting: expect.objectContaining({
embedding_provider_name: 'cohere',
embedding_model_name: 'embed-v3',
}),
}),
}),
}),
})
})
it('should handle switching from semantic to hybrid search with embedding model', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v3.0',
},
})
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
})
})
})
@@ -1,281 +0,0 @@
/**
* Integration Test: Document Management Flow
*
* Tests cross-module interactions: query state (URL-based) document list sorting
* document selection status filter utilities.
* Validates the data contract between documents page hooks and list component hooks.
*/
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
useRouter: () => ({ push: mockPush }),
usePathname: () => '/datasets/ds-1/documents',
}))
const { sanitizeStatusValue, normalizeStatusForQuery } =
await import('@/app/components/datasets/documents/status-filter')
const { useDocumentSort } =
await import('@/app/components/datasets/documents/components/document-list/hooks/use-document-sort')
const { useDocumentSelection } =
await import('@/app/components/datasets/documents/components/document-list/hooks/use-document-selection')
const { useDocumentListQueryState } =
await import('@/app/components/datasets/documents/hooks/use-document-list-query-state')
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const renderQueryStateHook = (searchParams = '') => {
return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams })
}
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc =>
({
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'test-doc.txt',
word_count: 500,
hit_count: 10,
created_at: Date.now() / 1000,
data_source_type: DataSourceType.FILE,
display_status: 'available',
indexing_status: 'completed',
enabled: true,
archived: false,
doc_type: null,
doc_metadata: null,
position: 1,
dataset_process_rule_id: 'rule-1',
...overrides,
}) as LocalDoc
describe('Document Management Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Status Filter Utilities', () => {
it('should sanitize valid status values', () => {
expect(sanitizeStatusValue('all')).toBe('all')
expect(sanitizeStatusValue('available')).toBe('available')
expect(sanitizeStatusValue('error')).toBe('error')
})
it('should fallback to "all" for invalid values', () => {
expect(sanitizeStatusValue(null)).toBe('all')
expect(sanitizeStatusValue(undefined)).toBe('all')
expect(sanitizeStatusValue('')).toBe('all')
expect(sanitizeStatusValue('nonexistent')).toBe('all')
})
it('should handle URL aliases', () => {
// 'active' is aliased to 'available'
expect(sanitizeStatusValue('active')).toBe('available')
})
it('should normalize status for API query', () => {
expect(normalizeStatusForQuery('all')).toBe('all')
// 'enabled' normalized to 'available' for query
expect(normalizeStatusForQuery('enabled')).toBe('available')
})
})
describe('URL-based Query State', () => {
it('should parse default query from empty URL params', () => {
const { result } = renderQueryStateHook()
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should update keyword query with replace history', async () => {
const { result, onUrlUpdate } = renderQueryStateHook()
act(() => {
result.current.updateQuery({ keyword: 'test', page: 2 })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('replace')
expect(update.searchParams.get('keyword')).toBe('test')
expect(update.searchParams.get('page')).toBe('2')
})
it('should reset query to defaults', async () => {
const { result, onUrlUpdate } = renderQueryStateHook()
act(() => {
result.current.resetQuery()
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('replace')
expect(update.searchParams.toString()).toBe('')
})
})
describe('Document Sort Integration', () => {
it('should derive sort field and order from remote sort value', () => {
const { result } = renderHook(() =>
useDocumentSort({
remoteSortValue: '-created_at',
onRemoteSortChange: vi.fn(),
}),
)
expect(result.current.sortField).toBe('created_at')
expect(result.current.sortOrder).toBe('desc')
})
it('should call remote sort change with descending sort for a new field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSort({
remoteSortValue: '-created_at',
onRemoteSortChange,
}),
)
act(() => {
result.current.handleSort('hit_count')
})
expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count')
})
it('should toggle descending to ascending when clicking active field', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSort({
remoteSortValue: '-hit_count',
onRemoteSortChange,
}),
)
act(() => {
result.current.handleSort('hit_count')
})
expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count')
})
it('should ignore null sort field updates', () => {
const onRemoteSortChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSort({
remoteSortValue: '-created_at',
onRemoteSortChange,
}),
)
act(() => {
result.current.handleSort(null)
})
expect(onRemoteSortChange).not.toHaveBeenCalled()
})
})
describe('Document Selection Integration', () => {
it('should keep checkbox selection state owned outside the hook', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}),
)
expect(result.current.downloadableSelectedIds).toEqual([])
expect(result.current.hasErrorDocumentsSelected).toBe(false)
expect(onSelectedIdChange).not.toHaveBeenCalled()
})
it('should identify downloadable selected documents (FILE type only)', () => {
const docs = [
createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }),
]
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}),
)
expect(result.current.downloadableSelectedIds).toEqual(['doc-1'])
})
it('should clear selection', () => {
const onSelectedIdChange = vi.fn()
const docs = [createDoc({ id: 'doc-1' })]
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange,
}),
)
act(() => {
result.current.clearSelection()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
})
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
it('should maintain consistent default state across all hooks', () => {
const docs = [createDoc({ id: 'doc-1' })]
const { result: queryResult } = renderQueryStateHook()
const { result: sortResult } = renderHook(() =>
useDocumentSort({
remoteSortValue: queryResult.current.query.sort,
onRemoteSortChange: vi.fn(),
}),
)
const { result: selResult } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange: vi.fn(),
}),
)
// Query defaults
expect(queryResult.current.query.sort).toBe('-created_at')
expect(queryResult.current.query.status).toBe('all')
// Sort state is derived from URL default sort.
expect(sortResult.current.sortField).toBe('created_at')
expect(sortResult.current.sortOrder).toBe('desc')
// Selection-derived batch metadata starts empty.
expect(selResult.current.downloadableSelectedIds).toEqual([])
expect(selResult.current.hasErrorDocumentsSelected).toBe(false)
})
})
})
@@ -1,215 +0,0 @@
/**
* Integration Test: External Knowledge Base Creation Flow
*
* Tests the data contract, validation logic, and API interaction
* for external knowledge base creation.
*/
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
import { describe, expect, it } from 'vitest'
// --- Factory ---
const createFormData = (overrides?: Partial<CreateKnowledgeBaseReq>): CreateKnowledgeBaseReq => ({
name: 'My External KB',
description: 'A test external knowledge base',
external_knowledge_api_id: 'api-1',
external_knowledge_id: 'ext-kb-123',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
...overrides,
})
describe('External Knowledge Base Creation Flow', () => {
describe('Data Contract: CreateKnowledgeBaseReq', () => {
it('should define a complete form structure', () => {
const form = createFormData()
expect(form).toHaveProperty('name')
expect(form).toHaveProperty('external_knowledge_api_id')
expect(form).toHaveProperty('external_knowledge_id')
expect(form).toHaveProperty('external_retrieval_model')
expect(form).toHaveProperty('provider')
expect(form.provider).toBe('external')
})
it('should include retrieval model settings', () => {
const form = createFormData()
expect(form.external_retrieval_model).toEqual({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
})
it('should allow partial overrides', () => {
const form = createFormData({
name: 'Custom Name',
external_retrieval_model: {
top_k: 10,
score_threshold: 0.8,
score_threshold_enabled: true,
},
})
expect(form.name).toBe('Custom Name')
expect(form.external_retrieval_model.top_k).toBe(10)
expect(form.external_retrieval_model.score_threshold_enabled).toBe(true)
})
})
describe('Form Validation Logic', () => {
const isFormValid = (form: CreateKnowledgeBaseReq): boolean => {
return (
form.name.trim() !== '' &&
form.external_knowledge_api_id !== '' &&
form.external_knowledge_id !== '' &&
form.external_retrieval_model.top_k !== undefined &&
form.external_retrieval_model.score_threshold !== undefined
)
}
it('should validate a complete form', () => {
const form = createFormData()
expect(isFormValid(form)).toBe(true)
})
it('should reject empty name', () => {
const form = createFormData({ name: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject whitespace-only name', () => {
const form = createFormData({ name: ' ' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_api_id', () => {
const form = createFormData({ external_knowledge_api_id: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_id', () => {
const form = createFormData({ external_knowledge_id: '' })
expect(isFormValid(form)).toBe(false)
})
})
describe('Form State Transitions', () => {
it('should start with empty default state', () => {
const defaultForm: CreateKnowledgeBaseReq = {
name: '',
description: '',
external_knowledge_api_id: '',
external_knowledge_id: '',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
}
// Verify default state matches component's initial useState
expect(defaultForm.name).toBe('')
expect(defaultForm.external_knowledge_api_id).toBe('')
expect(defaultForm.external_knowledge_id).toBe('')
expect(defaultForm.provider).toBe('external')
})
it('should support immutable form updates', () => {
const form = createFormData({ name: '' })
const updated = { ...form, name: 'Updated Name' }
expect(form.name).toBe('')
expect(updated.name).toBe('Updated Name')
// Other fields should remain unchanged
expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id)
})
it('should support retrieval model updates', () => {
const form = createFormData()
const updated = {
...form,
external_retrieval_model: {
...form.external_retrieval_model,
top_k: 10,
score_threshold_enabled: true,
},
}
expect(updated.external_retrieval_model.top_k).toBe(10)
expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true)
// Unchanged field
expect(updated.external_retrieval_model.score_threshold).toBe(0.5)
})
})
describe('API Call Data Contract', () => {
it('should produce a valid API payload from form data', () => {
const form = createFormData()
// The API expects the full CreateKnowledgeBaseReq
expect(form.name).toBeTruthy()
expect(form.external_knowledge_api_id).toBeTruthy()
expect(form.external_knowledge_id).toBeTruthy()
expect(form.provider).toBe('external')
expect(typeof form.external_retrieval_model.top_k).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean')
})
it('should support optional description', () => {
const formWithDesc = createFormData({ description: 'Some description' })
const formWithoutDesc = createFormData({ description: '' })
expect(formWithDesc.description).toBe('Some description')
expect(formWithoutDesc.description).toBe('')
})
it('should validate retrieval model bounds', () => {
const form = createFormData({
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
})
expect(form.external_retrieval_model.top_k).toBe(0)
expect(form.external_retrieval_model.score_threshold).toBe(0)
})
})
describe('External API List Integration', () => {
it('should validate API item structure', () => {
const apiItem = {
id: 'api-1',
name: 'Production API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'key-123',
},
}
expect(apiItem).toHaveProperty('id')
expect(apiItem).toHaveProperty('name')
expect(apiItem).toHaveProperty('settings')
expect(apiItem.settings).toHaveProperty('endpoint')
expect(apiItem.settings).toHaveProperty('api_key')
})
it('should link API selection to form data', () => {
const selectedApi = { id: 'api-2', name: 'Staging API' }
const form = createFormData({
external_knowledge_api_id: selectedApi.id,
})
expect(form.external_knowledge_api_id).toBe('api-2')
})
})
})
@@ -1,433 +0,0 @@
/**
* Integration Test: Hit Testing Flow
*
* Tests the query submission API response callback chain flow
* by rendering the actual QueryInput component and triggering user interactions.
* Validates that the production onSubmit logic correctly constructs payloads
* and invokes callbacks on success/failure.
*/
import type { HitTestingResponse, Query } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import QueryInput from '@/app/components/datasets/hit-testing/components/query-input'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })),
useDatasetDetailContextWithSelector: vi.fn(() => false),
}))
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({})),
useContextSelector: vi.fn(() => false),
createContext: vi.fn(() => ({})),
}))
vi.mock(
'@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing',
() => ({
default: ({
textArea,
actionButton,
}: {
textArea: React.ReactNode
actionButton: React.ReactNode
}) => (
<div data-testid="image-uploader-mock">
{textArea}
{actionButton}
</div>
),
}),
)
// --- Factories ---
const createRetrievalConfig = (overrides = {}): RetrievalConfig =>
({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_mode: undefined,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
weights: undefined,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
}) as RetrievalConfig
const createHitTestingResponse = (numResults: number): HitTestingResponse => ({
query: {
content: 'What is Dify?',
tsne_position: { x: 0, y: 0 },
},
records: Array.from({ length: numResults }, (_, i) => ({
segment: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
content: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
score: 0.95 - i * 0.1,
tsne_position: { x: 0, y: 0 },
child_chunks: null,
files: [],
})),
})
const createTextQuery = (content: string): Query[] => [
{ content, content_type: 'text_query', file_info: null },
]
// --- Helpers ---
const findSubmitButton = () => {
const buttons = screen.getAllByRole('button')
const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]'))
expect(submitButton).toBeTruthy()
return submitButton!
}
// --- Tests ---
describe('Hit Testing Flow', () => {
const mockHitTestingMutation = vi.fn()
const mockExternalMutation = vi.fn()
const mockSetHitResult = vi.fn()
const mockSetExternalHitResult = vi.fn()
const mockOnUpdateList = vi.fn()
const mockSetQueries = vi.fn()
const mockOnClickRetrievalMethod = vi.fn()
const mockOnSubmit = vi.fn()
const createDefaultProps = (overrides: Record<string, unknown> = {}) => ({
onUpdateList: mockOnUpdateList,
setHitResult: mockSetHitResult,
setExternalHitResult: mockSetExternalHitResult,
loading: false,
queries: [] as Query[],
setQueries: mockSetQueries,
isExternal: false,
onClickRetrievalMethod: mockOnClickRetrievalMethod,
retrievalConfig: createRetrievalConfig(),
isEconomy: false,
onSubmit: mockOnSubmit,
hitTestingMutation: mockHitTestingMutation,
externalKnowledgeBaseHitTestingMutation: mockExternalMutation,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Query Submission → API Call', () => {
it('should call hitTestingMutation with correct payload including retrieval model', async () => {
const retrievalConfig = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
})
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3))
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('How does RAG work?'),
retrievalConfig,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'How does RAG work?',
attachment_ids: [],
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
})
})
it('should override search_method to keywordSearch when isEconomy is true', async () => {
const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic })
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1))
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('test query'),
retrievalConfig,
isEconomy: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.keywordSearch,
}),
}),
expect.anything(),
)
})
})
it('should handle empty results by calling setHitResult with empty records', async () => {
const emptyResponse = createHitTestingResponse(0)
mockHitTestingMutation.mockImplementation(
async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(emptyResponse)
return emptyResponse
},
)
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('nonexistent topic'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(expect.objectContaining({ records: [] }))
})
})
it('should not call success callbacks when mutation resolves without onSuccess', async () => {
// Simulate a mutation that resolves but does not invoke the onSuccess callback
mockHitTestingMutation.mockResolvedValue(undefined)
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('test'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalled()
})
// Success callbacks should not fire when onSuccess is not invoked
expect(mockSetHitResult).not.toHaveBeenCalled()
expect(mockOnUpdateList).not.toHaveBeenCalled()
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
describe('API Response → Results Data Contract', () => {
it('should produce results with required segment fields for rendering', () => {
const response = createHitTestingResponse(3)
// Validate each result has the fields needed by ResultItem component
response.records.forEach((record) => {
expect(record.segment).toHaveProperty('id')
expect(record.segment).toHaveProperty('content')
expect(record.segment).toHaveProperty('position')
expect(record.segment).toHaveProperty('word_count')
expect(record.segment).toHaveProperty('document')
expect(record.segment.document).toHaveProperty('name')
expect(record.score).toBeGreaterThanOrEqual(0)
expect(record.score).toBeLessThanOrEqual(1)
})
})
it('should maintain correct score ordering', () => {
const response = createHitTestingResponse(5)
for (let i = 1; i < response.records.length; i++) {
expect(response.records[i - 1]!.score).toBeGreaterThanOrEqual(response.records[i]!.score)
}
})
it('should include document metadata for result item display', () => {
const response = createHitTestingResponse(1)
const record = response.records[0]
expect(record!.segment.document.name).toBeTruthy()
expect(record!.segment.document.data_source_type).toBeTruthy()
})
})
describe('Successful Submission → Callback Chain', () => {
it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => {
const response = createHitTestingResponse(3)
mockHitTestingMutation.mockImplementation(
async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
},
)
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('Test query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(response)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
})
})
it('should trigger records list refresh via onUpdateList after query', async () => {
const response = createHitTestingResponse(1)
mockHitTestingMutation.mockImplementation(
async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
},
)
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('new query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
describe('External KB Hit Testing', () => {
it('should use external mutation with correct payload for external datasets', async () => {
mockExternalMutation.mockImplementation(
async (
_params: unknown,
options?: { onSuccess?: (data: { records: never[] }) => void },
) => {
const response = { records: [] }
options?.onSuccess?.(response)
return response
},
)
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('test'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockExternalMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test',
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
// Internal mutation should NOT be called
expect(mockHitTestingMutation).not.toHaveBeenCalled()
})
})
it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
const externalResponse = { records: [] }
mockExternalMutation.mockImplementation(
async (
_params: unknown,
options?: { onSuccess?: (data: { records: never[] }) => void },
) => {
options?.onSuccess?.(externalResponse)
return externalResponse
},
)
render(
<QueryInput
{...createDefaultProps({
queries: createTextQuery('external query'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
})
@@ -1,320 +0,0 @@
/**
* Integration Test: Metadata Management Flow
*
* Tests the cross-module composition of metadata name validation, type constraints,
* and duplicate detection across the metadata management hooks.
*
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
* This integration test verifies:
* - Name validation combined with existing metadata list (duplicate detection)
* - Metadata type enum constraints matching expected data model
* - Full add/rename workflow: validate name check duplicates allow or reject
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
*/
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
import { renderHook } from '@testing-library/react'
import { DataType } from '@/app/components/datasets/metadata/types'
const { default: useCheckMetadataName } =
await import('@/app/components/datasets/metadata/hooks/use-check-metadata-name')
// --- Factory functions ---
const createMetadataItem = (
id: string,
name: string,
type = DataType.string,
count = 0,
): MetadataItemWithValueLength => ({
id,
name,
type,
count,
})
const createMetadataList = (): MetadataItemWithValueLength[] => [
createMetadataItem('meta-1', 'author', DataType.string, 5),
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
createMetadataItem('meta-5', 'version', DataType.number, 2),
]
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
describe('Name Validation Flow: Format Rules', () => {
it('should accept valid lowercase names with underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('valid_name').errorMsg).toBe('')
expect(result.current.checkName('author').errorMsg).toBe('')
expect(result.current.checkName('page_count').errorMsg).toBe('')
expect(result.current.checkName('v2_field').errorMsg).toBe('')
})
it('should reject empty names', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('').errorMsg).toBeTruthy()
})
it('should reject names with invalid characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
})
it('should reject names exceeding 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
const maxName = 'a'.repeat(255)
expect(result.current.checkName(maxName).errorMsg).toBe('')
})
})
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
it('should define exactly three data types', () => {
const typeValues = Object.values(DataType)
expect(typeValues).toHaveLength(3)
})
it('should include string, number, and time types', () => {
expect(DataType.string).toBe('string')
expect(DataType.number).toBe('number')
expect(DataType.time).toBe('time')
})
it('should use consistent types in metadata items', () => {
const metadataList = createMetadataList()
const stringItems = metadataList.filter((m) => m.type === DataType.string)
const numberItems = metadataList.filter((m) => m.type === DataType.number)
const timeItems = metadataList.filter((m) => m.type === DataType.time)
expect(stringItems).toHaveLength(2)
expect(numberItems).toHaveLength(2)
expect(timeItems).toHaveLength(1)
})
it('should enforce type-safe metadata item construction', () => {
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
expect(item.id).toBe('test-1')
expect(item.name).toBe('test_field')
expect(item.type).toBe(DataType.number)
expect(item.count).toBe(0)
})
})
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
it('should detect duplicate names against an existing metadata list', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const checkDuplicate = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return false
return existingMetadata.some((m) => m.name === newName)
}
expect(checkDuplicate('author')).toBe(true)
expect(checkDuplicate('created_date')).toBe(true)
expect(checkDuplicate('page_count')).toBe(true)
})
it('should allow names that do not conflict with existing metadata', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isNameAvailable = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return false
return !existingMetadata.some((m) => m.name === newName)
}
expect(isNameAvailable('category')).toBe(true)
expect(isNameAvailable('file_size')).toBe(true)
expect(isNameAvailable('language')).toBe(true)
})
it('should reject names that fail format validation before duplicate check', () => {
const { result } = renderHook(() => useCheckMetadataName())
const validateAndCheckDuplicate = (newName: string): { valid: boolean; reason: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return { valid: false, reason: 'format' }
return { valid: true, reason: '' }
}
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
expect(validateAndCheckDuplicate('').reason).toBe('format')
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
})
})
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
it('should allow an existing metadata item to keep its own name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return false
// Allow keeping the same name (skip self in duplicate check)
return !existingMetadata.some((m) => m.name === newName && m.id !== itemId)
}
// Author keeping its own name should be valid
expect(isRenameValid('meta-1', 'author')).toBe(true)
// page_count keeping its own name should be valid
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
})
it('should reject renaming to another existing metadata name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return false
return !existingMetadata.some((m) => m.name === newName && m.id !== itemId)
}
// Author trying to rename to "page_count" (taken by meta-3)
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
// version trying to rename to "source_url" (taken by meta-4)
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
})
it('should allow renaming to a completely new valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return false
return !existingMetadata.some((m) => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
})
it('should reject renaming with an invalid format even if name is unique', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return false
return !existingMetadata.some((m) => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
expect(isRenameValid('meta-3', '')).toBe(false)
})
})
describe('Full Metadata Management Workflow', () => {
it('should support a complete add-validate-check-duplicate cycle', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const addMetadataField = (
name: string,
type: DataType,
): { success: boolean; error?: string } => {
const formatCheck = result.current.checkName(name)
if (formatCheck.errorMsg) return { success: false, error: 'invalid_format' }
if (existingMetadata.some((m) => m.name === name))
return { success: false, error: 'duplicate_name' }
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
return { success: true }
}
// Add a valid new field
const result1 = addMetadataField('department', DataType.string)
expect(result1.success).toBe(true)
expect(existingMetadata).toHaveLength(6)
// Try to add a duplicate
const result2 = addMetadataField('author', DataType.string)
expect(result2.success).toBe(false)
expect(result2.error).toBe('duplicate_name')
expect(existingMetadata).toHaveLength(6)
// Try to add an invalid name
const result3 = addMetadataField('Invalid Name', DataType.string)
expect(result3.success).toBe(false)
expect(result3.error).toBe('invalid_format')
expect(existingMetadata).toHaveLength(6)
// Add another valid field
const result4 = addMetadataField('priority_level', DataType.number)
expect(result4.success).toBe(true)
expect(existingMetadata).toHaveLength(7)
})
it('should support a complete rename workflow with validation chain', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const renameMetadataField = (
itemId: string,
newName: string,
): { success: boolean; error?: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg) return { success: false, error: 'invalid_format' }
if (existingMetadata.some((m) => m.name === newName && m.id !== itemId))
return { success: false, error: 'duplicate_name' }
const item = existingMetadata.find((m) => m.id === itemId)
if (!item) return { success: false, error: 'not_found' }
// Simulate the rename in-place
const index = existingMetadata.indexOf(item)
existingMetadata[index] = { ...item, name: newName }
return { success: true }
}
// Rename author to document_author
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
expect(existingMetadata.find((m) => m.id === 'meta-1')?.name).toBe('document_author')
// Try renaming created_date to page_count (already taken)
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
// Rename to invalid format
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
// Rename non-existent item
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
})
it('should maintain validation consistency across multiple operations', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Validate the same name multiple times for consistency
const name = 'consistent_field'
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
expect(results.every((r) => r.errorMsg === '')).toBe(true)
// Validate an invalid name multiple times
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
expect(invalidResults.every((r) => r.errorMsg !== '')).toBe(true)
})
})
})
@@ -1,485 +0,0 @@
/**
* Integration Test: Pipeline Data Source Store Composition
*
* Tests cross-slice interactions in the pipeline data source Zustand store.
* The unit-level slice specs test each slice in isolation.
* This integration test verifies:
* - Store initialization produces correct defaults across all slices
* - Cross-slice coordination (e.g. credential shared across slices)
* - State isolation: changes in one slice do not affect others
* - Full workflow simulation through credential source data path
*/
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
import { CrawlStep } from '@/models/datasets'
import { OnlineDriveFileType } from '@/models/pipeline'
// --- Factory functions ---
const createFileItem = (id: string): FileItem => ({
fileID: id,
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
progress: 100,
})
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
title: title ?? `Page: ${url}`,
markdown: `# ${title ?? url}\n\nContent for ${url}`,
description: `Description for ${url}`,
source_url: url,
})
const createOnlineDriveFile = (
id: string,
name: string,
type = OnlineDriveFileType.file,
): OnlineDriveFile => ({
id,
name,
size: 2048,
type,
})
const createNotionPage = (pageId: string): NotionPage => ({
page_id: pageId,
page_name: `Page ${pageId}`,
page_icon: null,
is_bound: true,
parent_id: 'parent-1',
type: 'page',
workspace_id: 'ws-1',
})
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
describe('Store Initialization → All Slices Have Correct Defaults', () => {
it('should create a store with all five slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice defaults
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
// Local file slice defaults
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
// Online document slice defaults
expect(state.documentsData).toEqual([])
expect(state.onlineDocuments).toEqual([])
expect(state.searchValue).toBe('')
expect(state.selectedPagesId).toEqual(new Set())
// Website crawl slice defaults
expect(state.websitePages).toEqual([])
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
// Online drive slice defaults
expect(state.breadcrumbs).toEqual([])
expect(state.prefix).toEqual([])
expect(state.keywords).toBe('')
expect(state.selectedFileIds).toEqual([])
expect(state.onlineDriveFileList).toEqual([])
expect(state.bucket).toBe('')
expect(state.hasBucket).toBe(false)
})
})
describe('Cross-Slice Coordination: Shared Credential', () => {
it('should set credential that is accessible from the common slice', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-abc-123')
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
})
it('should allow credential update independently of all other slices', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
store.getState().setCurrentCredentialId('cred-xyz')
expect(store.getState().currentCredentialId).toBe('cred-xyz')
expect(store.getState().localFileList).toHaveLength(1)
})
})
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
it('should set and retrieve local file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toHaveLength(3)
expect(store.getState().localFileList[0]!.fileID).toBe('f1')
expect(store.getState().localFileList[2]!.fileID).toBe('f3')
})
it('should update preview ref when setting file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f-preview')]
store.getState().setLocalFileList(files)
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should clear files by setting empty list', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
expect(store.getState().localFileList).toHaveLength(1)
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
})
it('should set and clear current local file selection', () => {
const store = createDataSourceStore()
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toBeDefined()
expect(store.getState().currentLocalFile?.id).toBe('current-file')
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
it('should set documents data and online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toHaveLength(2)
expect(store.getState().onlineDocuments[0]!.page_id).toBe('page-1')
})
it('should update preview ref when setting online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-preview')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
})
it('should track selected page IDs', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
store.getState().setOnlineDocuments(pages)
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
expect(store.getState().selectedPagesId.size).toBe(2)
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
})
it('should manage search value for filtering documents', () => {
const store = createDataSourceStore()
store.getState().setSearchValue('meeting notes')
expect(store.getState().searchValue).toBe('meeting notes')
})
it('should set and clear current document selection', () => {
const store = createDataSourceStore()
const page = createNotionPage('current-page')
store.getState().setCurrentDocument(page)
expect(store.getState().currentDocument?.page_id).toBe('current-page')
store.getState().setCurrentDocument(undefined)
expect(store.getState().currentDocument).toBeUndefined()
})
})
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
it('should set website pages and update preview ref', () => {
const store = createDataSourceStore()
const pages = [
createCrawlResultItem('https://example.com'),
createCrawlResultItem('https://example.com/about'),
]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toHaveLength(2)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
})
it('should manage crawl step transitions', () => {
const store = createDataSourceStore()
expect(store.getState().step).toBe(CrawlStep.init)
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
store.getState().setStep(CrawlStep.finished)
expect(store.getState().step).toBe(CrawlStep.finished)
})
it('should set crawl result with data and timing', () => {
const store = createDataSourceStore()
const result = {
data: [createCrawlResultItem('https://test.com')],
time_consuming: 3.5,
}
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult?.data).toHaveLength(1)
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
})
it('should manage preview index for page navigation', () => {
const store = createDataSourceStore()
store.getState().setPreviewIndex(2)
expect(store.getState().previewIndex).toBe(2)
store.getState().setPreviewIndex(-1)
expect(store.getState().previewIndex).toBe(-1)
})
it('should set and clear current website selection', () => {
const store = createDataSourceStore()
const page = createCrawlResultItem('https://current.com')
store.getState().setCurrentWebsite(page)
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
it('should manage breadcrumb navigation', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
})
it('should support breadcrumb push/pop pattern', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
// Pop back one level
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
})
it('should manage file list and selection', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-1', 'report.pdf'),
createOnlineDriveFile('drive-2', 'data.csv'),
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
]
store.getState().setOnlineDriveFileList(files)
expect(store.getState().onlineDriveFileList).toHaveLength(3)
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
})
it('should update preview ref when selecting files', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-a', 'file-a.txt'),
createOnlineDriveFile('drive-b', 'file-b.txt'),
]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['drive-b'])
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
})
it('should manage bucket and prefix for S3-like navigation', () => {
const store = createDataSourceStore()
store.getState().setBucket('my-data-bucket')
store.getState().setPrefix(['data', '2024'])
store.getState().setHasBucket(true)
expect(store.getState().bucket).toBe('my-data-bucket')
expect(store.getState().prefix).toEqual(['data', '2024'])
expect(store.getState().hasBucket).toBe(true)
})
it('should manage keywords for search filtering', () => {
const store = createDataSourceStore()
store.getState().setKeywords('quarterly report')
expect(store.getState().keywords).toBe('quarterly report')
})
})
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
it('should keep local file state independent from online document state', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('local-1')])
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
expect(store.getState().localFileList).toHaveLength(1)
expect(store.getState().onlineDocuments).toHaveLength(1)
// Clearing local files should not affect online documents
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
expect(store.getState().onlineDocuments).toHaveLength(1)
})
it('should keep website crawl state independent from online drive state', () => {
const store = createDataSourceStore()
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
expect(store.getState().websitePages).toHaveLength(1)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
// Clearing website pages should not affect drive files
store.getState().setWebsitePages([])
expect(store.getState().websitePages).toHaveLength(0)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
})
it('should create fully independent store instances', () => {
const storeA = createDataSourceStore()
const storeB = createDataSourceStore()
storeA.getState().setCurrentCredentialId('cred-A')
storeA.getState().setLocalFileList([createFileItem('fa-1')])
expect(storeA.getState().currentCredentialId).toBe('cred-A')
expect(storeB.getState().currentCredentialId).toBe('')
expect(storeB.getState().localFileList).toEqual([])
})
})
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
it('should support a complete local file upload workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('upload-cred-1')
// Step 2: Set file list
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
store.getState().setLocalFileList(files)
// Step 3: Select current file for preview
store.getState().setCurrentLocalFile(files[0]!.file)
// Verify all state is consistent
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
expect(store.getState().localFileList).toHaveLength(2)
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should support a complete website crawl workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('crawl-cred-1')
// Step 2: Init crawl
store.getState().setStep(CrawlStep.running)
// Step 3: Crawl completes with results
const crawledPages = [
createCrawlResultItem('https://docs.example.com/guide'),
createCrawlResultItem('https://docs.example.com/api'),
createCrawlResultItem('https://docs.example.com/faq'),
]
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
store.getState().setStep(CrawlStep.finished)
// Step 4: Set website pages from results
store.getState().setWebsitePages(crawledPages)
// Step 5: Set preview
store.getState().setPreviewIndex(1)
// Verify all state
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
expect(store.getState().step).toBe(CrawlStep.finished)
expect(store.getState().websitePages).toHaveLength(3)
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
expect(store.getState().previewIndex).toBe(1)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe(
'https://docs.example.com/guide',
)
})
it('should support a complete online drive navigation workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('drive-cred-1')
// Step 2: Set bucket
store.getState().setBucket('company-docs')
store.getState().setHasBucket(true)
// Step 3: Navigate into folders
store.getState().setBreadcrumbs(['company-docs'])
store.getState().setPrefix(['projects'])
const folderFiles = [
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
]
store.getState().setOnlineDriveFileList(folderFiles)
// Step 4: Navigate deeper
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
// Step 5: Select files
store
.getState()
.setOnlineDriveFileList([
createOnlineDriveFile('doc-1', 'spec.pdf'),
createOnlineDriveFile('doc-2', 'design.fig'),
])
store.getState().setSelectedFileIds(['doc-1'])
// Verify full state
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
expect(store.getState().bucket).toBe('company-docs')
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
expect(store.getState().onlineDriveFileList).toHaveLength(2)
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
})
})
})
@@ -1,248 +0,0 @@
/**
* Integration Test: Segment CRUD Flow
*
* Tests segment selection, search/filter, and modal state management across hooks.
* Validates cross-hook data contracts in the completed segment module.
*/
import type { SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state'
import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter'
import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection'
const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel =>
({
id,
position: 1,
document_id: 'doc-1',
content,
sign_content: content,
answer: '',
word_count: 50,
tokens: 25,
keywords: ['test'],
index_node_id: 'idx-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: Date.now(),
indexing_at: Date.now(),
completed_at: Date.now(),
error: null,
stopped_at: 0,
updated_at: Date.now(),
attachments: [],
}) as SegmentDetailModel
describe('Segment CRUD Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Search and Filter → Segment List Query', () => {
it('should manage search input with debounce', () => {
vi.useFakeTimers()
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
act(() => {
result.current.handleInputChange('keyword')
})
expect(result.current.inputValue).toBe('keyword')
expect(result.current.searchValue).toBe('')
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.searchValue).toBe('keyword')
expect(onPageChange).toHaveBeenCalledWith(1)
vi.useRealTimers()
})
it('should manage status filter state', () => {
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
// status value 1 maps to !!1 = true (enabled)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// onChangeStatus converts: value === 'all' ? 'all' : !!value
expect(result.current.selectedStatus).toBe(true)
act(() => {
result.current.onClearFilter()
})
expect(result.current.selectedStatus).toBe('all')
expect(result.current.inputValue).toBe('')
})
it('should provide status list for filter dropdown', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
expect(result.current.statusList).toBeInstanceOf(Array)
expect(result.current.statusList.length).toBe(3) // all, disabled, enabled
})
it('should compute selectDefaultValue based on selectedStatus', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
// Initial state: 'all'
expect(result.current.selectDefaultValue).toBe('all')
// Set to enabled (true)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
expect(result.current.selectDefaultValue).toBe(1)
// Set to disabled (false)
act(() => {
result.current.onChangeStatus({ value: 0, name: 'disabled' })
})
expect(result.current.selectDefaultValue).toBe(0)
})
})
describe('Segment Selection → Batch Operations', () => {
it('should manage individual segment selection', () => {
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelectedSegmentIdsChange(['seg-1'])
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2'])
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
expect(result.current.selectedSegmentIds).toContain('seg-2')
expect(result.current.selectedSegmentIds).toHaveLength(2)
})
it('should clear selection via onCancelBatchOperation', () => {
const { result } = renderHook(() => useSegmentSelection())
act(() => {
result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2'])
})
expect(result.current.selectedSegmentIds).toHaveLength(2)
act(() => {
result.current.onCancelBatchOperation()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
})
})
describe('Modal State Management', () => {
const onNewSegmentModalChange = vi.fn()
it('should open segment detail modal on card click', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-detail-1', 'Detail content')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
expect(result.current.currSegment.segInfo).toBeDefined()
expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1')
})
it('should close segment detail modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-1')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
act(() => {
result.current.onCloseSegmentDetail()
})
expect(result.current.currSegment.showModal).toBe(false)
})
it('should manage full screen toggle', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.fullScreen).toBe(false)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(true)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(false)
})
it('should manage collapsed state', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.isCollapsed).toBe(true)
act(() => {
result.current.toggleCollapsed()
})
expect(result.current.isCollapsed).toBe(false)
})
it('should manage new child segment modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.showNewChildSegmentModal).toBe(false)
act(() => {
result.current.handleAddNewChildChunk('chunk-parent-1')
})
expect(result.current.showNewChildSegmentModal).toBe(true)
expect(result.current.currChunkId).toBe('chunk-parent-1')
act(() => {
result.current.onCloseNewChildChunkModal()
})
expect(result.current.showNewChildSegmentModal).toBe(false)
})
})
describe('Cross-Hook Data Flow: Search → Selection → Modal', () => {
it('should maintain independent state across all three hooks', () => {
const segments = [createSegment('seg-1'), createSegment('seg-2')]
const { result: filterResult } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
const { result: selectionResult } = renderHook(() => useSegmentSelection())
const { result: modalResult } = renderHook(() =>
useModalState({ onNewSegmentModalChange: vi.fn() }),
)
// Set search filter to enabled
act(() => {
filterResult.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// Select a segment
act(() => {
selectionResult.current.onSelectedSegmentIdsChange(['seg-1'])
})
// Open detail modal
act(() => {
modalResult.current.onClickCard(segments[0]!)
})
// All states should be independent
expect(filterResult.current.selectedStatus).toBe(true) // !!1
expect(selectionResult.current.selectedSegmentIds).toContain('seg-1')
expect(modalResult.current.currSegment.showModal).toBe(true)
})
})
})
@@ -1,95 +0,0 @@
/**
* Description Validation Test
*
* Tests for the 400-character description validation across App and Dataset
* creation and editing workflows to ensure consistent validation behavior.
*/
describe('Description Validation Logic', () => {
// Simulate backend validation function
const validateDescriptionLength = (description?: string | null) => {
if (description && description.length > 400)
throw new Error('Description cannot exceed 400 characters.')
return description
}
describe('Backend Validation Function', () => {
it('allows description within 400 characters', () => {
const validDescription = 'x'.repeat(400)
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
expect(validateDescriptionLength(validDescription)).toBe(validDescription)
})
it('allows empty description', () => {
expect(() => validateDescriptionLength('')).not.toThrow()
expect(() => validateDescriptionLength(null)).not.toThrow()
expect(() => validateDescriptionLength(undefined)).not.toThrow()
})
it('rejects description exceeding 400 characters', () => {
const invalidDescription = 'x'.repeat(401)
expect(() => validateDescriptionLength(invalidDescription)).toThrow(
'Description cannot exceed 400 characters.',
)
})
})
describe('Backend Validation Consistency', () => {
it('App and Dataset have consistent validation limits', () => {
const maxLength = 400
const validDescription = 'x'.repeat(maxLength)
const invalidDescription = 'x'.repeat(maxLength + 1)
// Both should accept exactly 400 characters
expect(validDescription.length).toBe(400)
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
// Both should reject 401 characters
expect(invalidDescription.length).toBe(401)
expect(() => validateDescriptionLength(invalidDescription)).toThrow()
})
it('validation error messages are consistent', () => {
const expectedErrorMessage = 'Description cannot exceed 400 characters.'
// This would be the error message from both App and Dataset backend validation
expect(expectedErrorMessage).toBe('Description cannot exceed 400 characters.')
const invalidDescription = 'x'.repeat(401)
try {
validateDescriptionLength(invalidDescription)
} catch (error) {
expect((error as Error).message).toBe(expectedErrorMessage)
}
})
})
describe('Character Length Edge Cases', () => {
const testCases = [
{ length: 0, shouldPass: true, description: 'empty description' },
{ length: 1, shouldPass: true, description: '1 character' },
{ length: 399, shouldPass: true, description: '399 characters' },
{ length: 400, shouldPass: true, description: '400 characters (boundary)' },
{ length: 401, shouldPass: false, description: '401 characters (over limit)' },
{ length: 500, shouldPass: false, description: '500 characters' },
{ length: 1000, shouldPass: false, description: '1000 characters' },
]
testCases.forEach(({ length, shouldPass, description }) => {
it(`handles ${description} correctly`, () => {
const testDescription = length > 0 ? 'x'.repeat(length) : ''
expect(testDescription.length).toBe(length)
if (shouldPass) {
expect(() => validateDescriptionLength(testDescription)).not.toThrow()
expect(validateDescriptionLength(testDescription)).toBe(testDescription)
} else {
expect(() => validateDescriptionLength(testDescription)).toThrow(
'Description cannot exceed 400 characters.',
)
}
})
})
})
})
@@ -1,184 +0,0 @@
/**
* Integration test: API Key management flow
*
* Tests the cross-component interaction:
* ApiServer SecretKeyButton SecretKeyModal
*
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
* with only service-layer mocks. Deep modal interactions (create/delete)
* are covered by unit tests in secret-key-modal.spec.tsx.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ApiServer from '@/app/components/develop/ApiServer'
// ---------- fake timers (modal transitions) ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
async function flushUI() {
await act(async () => {
vi.runAllTimers()
})
}
// ---------- mocks ----------
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((val: number) => `Time:${val}`),
formatDate: vi.fn((val: string) => `Date:${val}`),
}),
}))
vi.mock('@/service/apps', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/datasets', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
const mockIsLoading = vi.fn().mockReturnValue(false)
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => ({
data: mockApiKeys(),
isLoading: mockIsLoading(),
}),
useInvalidateAppApiKeys: () => vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))
// ---------- tests ----------
describe('API Key management flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApiKeys.mockReturnValue({ data: [] })
mockIsLoading.mockReturnValue(false)
})
it('ApiServer renders URL, status badge, and API Key button', () => {
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appApi.apiKey' })).toBeDisabled()
})
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" canManageApiKey />)
// Click API Key button (rendered by SecretKeyButton)
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
// SecretKeyModal should render with real modal content
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
})
})
it('modal shows loading state when API keys are being fetched', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
mockIsLoading.mockReturnValue(true)
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" canManageApiKey />)
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Loading indicator should be present
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
})
it('modal can be closed by clicking X icon', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" canManageApiKey />)
// Open modal
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Click X icon to close
const closeIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
await act(async () => {
await user.click(closeIcon!)
})
await flushUI()
// Modal should close
await waitFor(() => {
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
})
})
it('renders correctly with different API URLs', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { rerender } = render(
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" canManageApiKey />,
)
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
// Open modal and verify it works with the same appId
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
// Close modal, update URL and re-verify
const xIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid.cursor-pointer')
await act(async () => {
await user.click(xIcon!)
})
await flushUI()
rerender(
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" canManageApiKey />,
)
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
})
})
@@ -1,230 +0,0 @@
/**
* Integration test: DevelopMain page flow
*
* Tests the full page lifecycle:
* Loading state App loaded Header (ApiServer) + Content (Doc) rendered
*
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DevelopMain from '@/app/components/develop'
import { AppModeEnum, Theme } from '@/types/app'
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
async function flushUI() {
await act(async () => {
vi.runAllTimers()
})
}
let storeAppDetail: unknown
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
return selector({ appDetail: storeAppDetail })
},
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: Theme.light }),
}))
vi.mock('@/i18n-config/language', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/i18n-config/language')>()
return {
...actual,
}
})
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((val: number) => `Time:${val}`),
formatDate: vi.fn((val: string) => `Date:${val}`),
}),
}))
vi.mock('@/service/apps', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/datasets', () => ({
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
delApikey: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
useInvalidateAppApiKeys: () => vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))
// ---------- tests ----------
describe('DevelopMain page flow', () => {
beforeEach(() => {
vi.clearAllMocks()
storeAppDetail = undefined
})
it('should show loading indicator when appDetail is not available', () => {
storeAppDetail = undefined
render(<DevelopMain appId="app-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
// No content should be visible
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
})
it('should render full page when appDetail is loaded', () => {
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
permission_keys: ['app.acl.edit'],
}
render(<DevelopMain appId="app-1" />)
// ApiServer section should be visible
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
// Loading should NOT be visible
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should render Doc component with correct app mode template', () => {
storeAppDetail = {
id: 'app-1',
name: 'Chat App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
permission_keys: ['app.acl.edit'],
}
const { container } = render(<DevelopMain appId="app-1" />)
// Doc renders an article element with prose classes
const article = container.querySelector('article')
expect(article).toBeInTheDocument()
expect(article?.className).toContain('prose')
})
it('should transition from loading to content when appDetail becomes available', () => {
// Start with no data
storeAppDetail = undefined
const { rerender } = render(<DevelopMain appId="app-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
// Simulate store update
storeAppDetail = {
id: 'app-1',
name: 'My App',
api_base_url: 'https://api.example.com/v1',
mode: AppModeEnum.COMPLETION,
permission_keys: ['app.acl.edit'],
}
rerender(<DevelopMain appId="app-1" />)
// Content should now be visible
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
})
it('should open API key modal from the page', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.WORKFLOW,
permission_keys: ['app.acl.edit'],
}
render(<DevelopMain appId="app-1" />)
// Click API Key button in the header
await act(async () => {
await user.click(screen.getByText('appApi.apiKey'))
})
await flushUI()
// SecretKeyModal should open
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
})
it('should render correctly for different app modes', () => {
const modes = [
AppModeEnum.CHAT,
AppModeEnum.COMPLETION,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
]
for (const mode of modes) {
storeAppDetail = {
id: 'app-1',
name: `${mode} App`,
api_base_url: 'https://api.test.com/v1',
mode,
permission_keys: ['app.acl.edit'],
}
const { container, unmount } = render(<DevelopMain appId="app-1" />)
// ApiServer should always be present
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
// Doc should render an article
expect(container.querySelector('article')).toBeInTheDocument()
unmount()
}
})
it('should have correct page layout structure', () => {
storeAppDetail = {
id: 'app-1',
name: 'Test App',
api_base_url: 'https://api.test.com/v1',
mode: AppModeEnum.CHAT,
permission_keys: ['app.acl.edit'],
}
render(<DevelopMain appId="app-1" />)
// Main container: flex column with full height
const mainDiv = screen.getByTestId('develop-main')
expect(mainDiv.className).toContain('flex')
expect(mainDiv.className).toContain('flex-col')
expect(mainDiv.className).toContain('h-full')
// Header section with border
const header = mainDiv.querySelector('.border-b')
expect(header).toBeInTheDocument()
// Content section with overflow scroll
const content = mainDiv.querySelector('.overflow-auto')
expect(content).toBeInTheDocument()
})
})
@@ -1,318 +0,0 @@
import type { Mock } from 'vitest'
/**
* Document Detail Navigation Fix Verification Test
*
* This test specifically validates that the backToPrev function in the document detail
* component correctly preserves pagination and filter states.
*/
import { fireEvent, render, screen } from '@testing-library/react'
import { useRouter } from '@/next/navigation'
import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document'
// Mock Next.js router
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(() => ({
push: mockPush,
})),
}))
// Mock the document service hooks
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDetail: vi.fn(),
useDocumentMetadata: vi.fn(),
useInvalidDocumentList: vi.fn(() => vi.fn()),
}))
// Mock other dependencies
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContext: vi.fn(() => [null]),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: vi.fn(() => vi.fn()),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useSegmentListKey: vi.fn(),
useChildSegmentListKey: vi.fn(),
}))
// Create a minimal version of the DocumentDetail component that includes our fix
const DocumentDetailWithFix = ({
datasetId,
documentId,
}: {
datasetId: string
documentId: string
}) => {
const router = useRouter()
// This is the FIXED implementation from detail/index.tsx
const backToPrev = () => {
// Preserve pagination and filter states when navigating back
const searchParams = new URLSearchParams(window.location.search)
const queryString = searchParams.toString()
const separator = queryString ? '?' : ''
const backPath = `/datasets/${datasetId}/documents${separator}${queryString}`
router.push(backPath)
}
return (
<div data-testid="document-detail-fixed">
<button type="button" data-testid="back-button-fixed" onClick={backToPrev}>
Back to Documents
</button>
<div data-testid="document-info">
Dataset: {datasetId}, Document: {documentId}
</div>
</div>
)
}
describe('Document Detail Navigation Fix Verification', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock successful API responses
;(useDocumentDetail as Mock).mockReturnValue({
data: {
id: 'doc-123',
name: 'Test Document',
display_status: 'available',
enabled: true,
archived: false,
},
error: null,
})
;(useDocumentMetadata as Mock).mockReturnValue({
data: null,
error: null,
})
})
describe('Query Parameter Preservation', () => {
it('preserves pagination state (page 3, limit 25)', () => {
// Simulate user coming from page 3 with 25 items per page
Object.defineProperty(window, 'location', {
value: {
search: '?page=3&limit=25',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
// User clicks back button
fireEvent.click(screen.getByTestId('back-button-fixed'))
// Should preserve the pagination state
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=3&limit=25')
console.log('✅ Pagination state preserved: page=3&limit=25')
})
it('preserves search keyword and filters', () => {
// Simulate user with search and filters applied
Object.defineProperty(window, 'location', {
value: {
search: '?page=2&limit=10&keyword=API%20documentation&status=active',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
fireEvent.click(screen.getByTestId('back-button-fixed'))
// Should preserve all query parameters
expect(mockPush).toHaveBeenCalledWith(
'/datasets/dataset-123/documents?page=2&limit=10&keyword=API+documentation&status=active',
)
console.log('✅ Search and filters preserved')
})
it('handles complex query parameters with special characters', () => {
// Test with complex query string including encoded characters
Object.defineProperty(window, 'location', {
value: {
search:
'?page=1&limit=50&keyword=test%20%26%20debug&sort=name&order=desc&filter=%7B%22type%22%3A%22pdf%22%7D',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
fireEvent.click(screen.getByTestId('back-button-fixed'))
// URLSearchParams will normalize the encoding, but preserve all parameters
const expectedCall = mockPush.mock.calls[0]![0]
expect(expectedCall).toMatch(/^\/datasets\/dataset-123\/documents\?/)
expect(expectedCall).toMatch(/page=1/)
expect(expectedCall).toMatch(/limit=50/)
expect(expectedCall).toMatch(/keyword=test/)
expect(expectedCall).toMatch(/sort=name/)
expect(expectedCall).toMatch(/order=desc/)
console.log('✅ Complex query parameters handled:', expectedCall)
})
it('handles empty query parameters gracefully', () => {
// No query parameters in URL
Object.defineProperty(window, 'location', {
value: {
search: '',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
fireEvent.click(screen.getByTestId('back-button-fixed'))
// Should navigate to clean documents URL
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents')
console.log('✅ Empty parameters handled gracefully')
})
})
describe('Different Dataset IDs', () => {
it('works with different dataset identifiers', () => {
Object.defineProperty(window, 'location', {
value: {
search: '?page=5&limit=10',
},
writable: true,
})
// Test with different dataset ID format
render(<DocumentDetailWithFix datasetId="ds-prod-2024-001" documentId="doc-456" />)
fireEvent.click(screen.getByTestId('back-button-fixed'))
expect(mockPush).toHaveBeenCalledWith('/datasets/ds-prod-2024-001/documents?page=5&limit=10')
console.log('✅ Works with different dataset ID formats')
})
})
describe('Real User Scenarios', () => {
it('scenario: user searches, goes to page 3, views document, clicks back', () => {
// User searched for "API" and navigated to page 3
Object.defineProperty(window, 'location', {
value: {
search: '?keyword=API&page=3&limit=10',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="main-dataset" documentId="api-doc-123" />)
// User decides to go back to continue browsing
fireEvent.click(screen.getByTestId('back-button-fixed'))
// Should return to page 3 of API search results
expect(mockPush).toHaveBeenCalledWith(
'/datasets/main-dataset/documents?keyword=API&page=3&limit=10',
)
console.log('✅ Real user scenario: search + pagination preserved')
})
it('scenario: user applies multiple filters, goes to document, returns', () => {
// User has applied multiple filters and is on page 2
Object.defineProperty(window, 'location', {
value: {
search: '?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="filtered-dataset" documentId="filtered-doc" />)
fireEvent.click(screen.getByTestId('back-button-fixed'))
// All filters should be preserved
expect(mockPush).toHaveBeenCalledWith(
'/datasets/filtered-dataset/documents?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc',
)
console.log('✅ Complex filtering scenario preserved')
})
})
describe('Error Handling and Edge Cases', () => {
it('handles malformed query parameters gracefully', () => {
// Test with potentially problematic query string
Object.defineProperty(window, 'location', {
value: {
search: '?page=invalid&limit=&keyword=test&=emptykey&malformed',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
// Should not throw errors
expect(() => {
fireEvent.click(screen.getByTestId('back-button-fixed'))
}).not.toThrow()
// Should still attempt navigation (URLSearchParams will clean up the parameters)
expect(mockPush).toHaveBeenCalled()
const navigationPath = mockPush.mock.calls[0]![0]
expect(navigationPath).toMatch(/^\/datasets\/dataset-123\/documents/)
console.log('✅ Malformed parameters handled gracefully:', navigationPath)
})
it('handles very long query strings', () => {
// Test with a very long query string
const longKeyword = 'a'.repeat(1000)
Object.defineProperty(window, 'location', {
value: {
search: `?page=1&keyword=${longKeyword}`,
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
expect(() => {
fireEvent.click(screen.getByTestId('back-button-fixed'))
}).not.toThrow()
expect(mockPush).toHaveBeenCalled()
console.log('✅ Long query strings handled')
})
})
describe('Performance Verification', () => {
it('navigation function executes quickly', () => {
Object.defineProperty(window, 'location', {
value: {
search: '?page=1&limit=10&keyword=test',
},
writable: true,
})
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
const startTime = performance.now()
fireEvent.click(screen.getByTestId('back-button-fixed'))
const endTime = performance.now()
const executionTime = endTime - startTime
// Should execute in less than 10ms
expect(executionTime).toBeLessThan(10)
console.log(`⚡ Navigation execution time: ${executionTime.toFixed(2)}ms`)
})
})
})
@@ -1,82 +0,0 @@
/**
* Document List Sorting Tests
*/
describe('Document List Sorting', () => {
const mockDocuments = [
{ id: '1', name: 'Beta.pdf', word_count: 500, hit_count: 10, created_at: 1699123456 },
{ id: '2', name: 'Alpha.txt', word_count: 200, hit_count: 25, created_at: 1699123400 },
{ id: '3', name: 'Gamma.docx', word_count: 800, hit_count: 5, created_at: 1699123500 },
]
const sortDocuments = (docs: any[], field: string, order: 'asc' | 'desc') => {
return [...docs].sort((a, b) => {
let aValue: any
let bValue: any
switch (field) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'word_count':
aValue = a.word_count || 0
bValue = b.word_count || 0
break
case 'hit_count':
aValue = a.hit_count || 0
bValue = b.hit_count || 0
break
case 'created_at':
aValue = a.created_at
bValue = b.created_at
break
default:
return 0
}
if (field === 'name') {
const result = aValue.localeCompare(bValue)
return order === 'asc' ? result : -result
} else {
const result = aValue - bValue
return order === 'asc' ? result : -result
}
})
}
it('sorts by name descending (default for UI consistency)', () => {
const sorted = sortDocuments(mockDocuments, 'name', 'desc')
expect(sorted.map((doc) => doc.name)).toEqual(['Gamma.docx', 'Beta.pdf', 'Alpha.txt'])
})
it('sorts by name ascending (after toggle)', () => {
const sorted = sortDocuments(mockDocuments, 'name', 'asc')
expect(sorted.map((doc) => doc.name)).toEqual(['Alpha.txt', 'Beta.pdf', 'Gamma.docx'])
})
it('sorts by word_count descending', () => {
const sorted = sortDocuments(mockDocuments, 'word_count', 'desc')
expect(sorted.map((doc) => doc.word_count)).toEqual([800, 500, 200])
})
it('sorts by hit_count descending', () => {
const sorted = sortDocuments(mockDocuments, 'hit_count', 'desc')
expect(sorted.map((doc) => doc.hit_count)).toEqual([25, 10, 5])
})
it('sorts by created_at descending (newest first)', () => {
const sorted = sortDocuments(mockDocuments, 'created_at', 'desc')
expect(sorted.map((doc) => doc.created_at)).toEqual([1699123500, 1699123456, 1699123400])
})
it('handles empty values correctly', () => {
const docsWithEmpty = [
{ id: '1', name: 'Test', word_count: 100, hit_count: 5, created_at: 1699123456 },
{ id: '2', name: 'Empty', word_count: 0, hit_count: 0, created_at: 1699123400 },
]
const sorted = sortDocuments(docsWithEmpty, 'word_count', 'desc')
expect(sorted.map((doc) => doc.word_count)).toEqual([100, 0])
})
})
@@ -1,459 +0,0 @@
/**
* Integration test: Explore App List Flow
*
* Tests the end-to-end user flow of browsing, filtering, searching,
* and adding apps to workspace from the explore page.
*/
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import {
createTestQueryClient,
renderWithSystemFeatures as render,
} from '@/__tests__/utils/mock-system-features'
import AppList from '@/app/components/explore/app-list'
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { AppModeEnum } from '@/types/app'
type MockAppContext = {
userProfile: { id: string }
workspacePermissionKeys: string[]
}
const mockUseAppContext = vi.hoisted(() => vi.fn<() => MockAppContext>())
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en-US"}'
let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn()
let mockExploreData: { categories: string[]; allList: App[] } | undefined
let mockIsLoading = false
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockTabValue, mockSetTab],
}
})
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: () => setTimeout(() => fnRef.current(), 0),
}
},
}
})
vi.mock('@/service/use-explore', () => ({
useLearnDifyAppList: () => ({
data: [],
isLoading: false,
isError: false,
}),
}))
vi.mock('@/service/explore', () => ({
fetchAppDetail: vi.fn(),
fetchAppList: vi.fn(),
fetchBanners: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {},
consoleQuery: {
systemFeatures: {
get: {
queryKey: () => ['console', 'systemFeatures'],
},
},
apps: {
get: {
queryOptions: (options: {
input?: { query?: { limit?: number } }
select?: (response: {
data: []
has_more: boolean
limit: number
page: number
total: number
}) => unknown
}) => {
const limit = options.input?.query?.limit ?? 0
const response = {
data: [],
has_more: false,
limit,
page: 1,
total: 0,
}
return {
queryKey: ['console', 'apps', 'get', options.input],
queryFn: () => Promise.resolve(response),
initialData: response,
select: options.select,
}
},
},
},
explore: {
apps: {
get: {
queryKey: ({ input }: { input?: unknown } = {}) => [
'console',
'explore',
'apps',
'get',
input,
],
},
},
banners: {
get: {
queryKey: ({ input }: { input?: unknown } = {}) => [
'console',
'explore',
'banners',
'get',
input,
],
},
},
},
},
}))
vi.mock('@/context/account-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext())
})
vi.mock('@/context/workspace-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext())
})
vi.mock('@/context/permission-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext())
})
vi.mock('@/context/version-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext())
})
vi.mock('@/context/system-features-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext())
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } =
await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
handleImportDSLConfirm: mockHandleImportDSLConfirm,
versions: ['v1'],
isFetching: false,
}),
}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: (props: CreateAppModalProps) => {
if (!props.show) return null
return (
<div data-testid="create-app-modal">
<button
data-testid="confirm-create"
onClick={() =>
props.onConfirm({
name: 'New App',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: 'desc',
})
}
>
confirm
</button>
<button data-testid="hide-create" onClick={props.onHide}>
hide
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => (
<div data-testid="dsl-confirm-modal">
<button data-testid="dsl-confirm" onClick={onConfirm}>
confirm
</button>
<button data-testid="dsl-cancel" onClick={onCancel}>
cancel
</button>
</div>
),
}))
const createApp = (overrides: Partial<App> = {}): App => ({
app: {
id: overrides.app?.id ?? 'app-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '😀',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'Alpha',
description: overrides.app?.description ?? 'Alpha description',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
can_trial: true,
app_id: overrides.app_id ?? 'app-1',
description: overrides.description ?? 'Alpha description',
copyright: overrides.copyright ?? '',
privacy_policy: overrides.privacy_policy ?? null,
custom_disclaimer: overrides.custom_disclaimer ?? null,
categories: overrides.categories ?? ['Writing'],
position: overrides.position ?? 1,
is_listed: overrides.is_listed ?? true,
install_count: overrides.install_count ?? 0,
installed: overrides.installed ?? false,
editable: overrides.editable ?? false,
is_agent: overrides.is_agent ?? false,
})
const mockMemberRole = (hasEditPermission: boolean) => {
mockUseAppContext.mockReturnValue({
userProfile: { id: 'user-1' },
workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [],
})
vi.mocked(useMembers).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
},
} as unknown as ReturnType<typeof useMembers>)
}
const localeInput = { query: { language: 'en-US' } }
const exploreAppListQueryKey = ['console', 'explore', 'apps', 'get', localeInput, 'en-US']
const homeContinueWorkAppsInput = {
query: {
page: 1,
limit: 8,
name: '',
},
}
const createHomeQueryClient = () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['console', 'apps', 'get', homeContinueWorkAppsInput], {
data: [],
has_more: false,
limit: 8,
page: 1,
total: 0,
})
if (!mockIsLoading && mockExploreData)
queryClient.setQueryData(exploreAppListQueryKey, mockExploreData)
return queryClient
}
const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return render(<AppList onSuccess={onSuccess} />, {
queryClient: createHomeQueryClient(),
})
}
describe('Explore App List Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTabValue = allCategoriesEn
mockIsLoading = false
mockExploreData = {
categories: ['Writing', 'Translate', 'Programming'],
allList: [
createApp({
app_id: 'app-1',
app: { ...createApp().app, name: 'Writer Bot' },
categories: ['Writing'],
}),
createApp({
app_id: 'app-2',
app: { ...createApp().app, id: 'app-id-2', name: 'Translator' },
categories: ['Translate'],
}),
createApp({
app_id: 'app-3',
app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' },
categories: ['Programming'],
}),
],
}
;(fetchAppList as unknown as Mock).mockImplementation(() => new Promise(() => {}))
;(fetchBanners as unknown as Mock).mockResolvedValue([])
})
describe('Browse and Filter Flow', () => {
it('should display all apps when no category filter is applied', () => {
renderAppList()
expect(screen.getByText('Writer Bot'))!.toBeInTheDocument()
expect(screen.getByText('Translator'))!.toBeInTheDocument()
expect(screen.getByText('Code Helper'))!.toBeInTheDocument()
})
it('should filter apps by selected category', () => {
mockTabValue = 'Writing'
renderAppList()
expect(screen.getByText('Writer Bot'))!.toBeInTheDocument()
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
})
it('should only use categories when filtering by selected category', () => {
mockTabValue = 'Writing'
mockExploreData = {
categories: ['Writing', 'Translate'],
allList: [
createApp({
app_id: 'app-1',
app: { ...createApp().app, name: 'Active Writer' },
categories: ['Writing'],
}),
createApp({
app_id: 'app-2',
app: { ...createApp().app, id: 'app-id-2', name: 'Legacy Writer' },
categories: [],
}),
],
}
renderAppList()
expect(screen.getByText('Active Writer')).toBeInTheDocument()
expect(screen.queryByText('Legacy Writer')).not.toBeInTheDocument()
})
it('should filter apps by search keyword', async () => {
renderAppList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'trans' } })
await waitFor(() => {
expect(screen.getByText('Translator'))!.toBeInTheDocument()
expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument()
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
})
})
})
describe('Add to Workspace Flow', () => {
it('should complete the full add-to-workspace flow with DSL confirmation', async () => {
// Step 1: User clicks "Add to Workspace" on an app card
const onSuccess = vi.fn()
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
mockHandleImportDSL.mockImplementation(
async (_payload: unknown, options: { onSuccess?: () => void; onPending?: () => void }) => {
options.onPending?.()
},
)
mockHandleImportDSLConfirm.mockImplementation(
async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
},
)
renderAppList(true, onSuccess)
// Step 2: Click the app card - opens create modal in self-hosted/non-cloud mode
fireEvent.click(screen.getByRole('button', { name: 'Writer Bot' }))
// Step 3: Confirm creation in modal
fireEvent.click(await screen.findByTestId('confirm-create'))
// Step 4: API fetches app detail
await waitFor(() => {
expect(fetchAppDetail).toHaveBeenCalledWith('app-id')
})
// Step 5: DSL import triggers pending confirmation
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
// Step 6: DSL confirm modal appears and user confirms
expect(await screen.findByTestId('dsl-confirm-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('dsl-confirm'))
// Step 7: Flow completes successfully
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
})
describe('Loading and Empty States', () => {
it('should transition from loading to content', () => {
// Step 1: Loading state
mockIsLoading = true
mockExploreData = undefined
const { unmount } = renderAppList()
expect(screen.getByRole('status'))!.toBeInTheDocument()
// Step 2: Data loads
mockIsLoading = false
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
unmount()
renderAppList()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('Alpha'))!.toBeInTheDocument()
})
})
describe('Permission-Based Behavior', () => {
it('should not make app cards clickable when user has no edit permission', () => {
renderAppList(false)
expect(screen.queryByRole('button', { name: 'Writer Bot' })).not.toBeInTheDocument()
})
it('should make app cards clickable when user has edit permission', () => {
renderAppList(true)
expect(screen.getByRole('button', { name: 'Writer Bot' })).toBeInTheDocument()
})
})
})
@@ -1,271 +0,0 @@
/**
* Integration test: Installed App Flow
*
* Tests the end-to-end user flow of installed apps: sidebar navigation,
* mode-based routing (Chat / Completion / Workflow), and lifecycle
* operations (pin/unpin, delete).
*/
import type { Mock } from 'vitest'
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import InstalledApp from '@/app/components/explore/installed-app'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import {
useGetInstalledAppAccessModeByAppId,
useGetInstalledAppMeta,
useGetInstalledAppParams,
useGetInstalledApps,
} from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: vi.fn(),
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: vi.fn(),
useGetInstalledAppParams: vi.fn(),
useGetInstalledAppMeta: vi.fn(),
useGetInstalledApps: vi.fn(),
}))
vi.mock('@/app/components/share/text-generation', () => ({
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
<div data-testid="text-generation-app">
Text Generation
{isWorkflow && ' (Workflow)'}
</div>
),
}))
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
<div data-testid="chat-with-history">Chat - {installedAppInfo?.app.name}</div>
),
}))
describe('Installed App Flow', () => {
const mockUpdateAppInfo = vi.fn()
const mockUpdateWebAppAccessMode = vi.fn()
const mockUpdateAppParams = vi.fn()
const mockUpdateWebAppMeta = vi.fn()
const mockUpdateUserCanAccessApp = vi.fn()
const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({
id: 'installed-app-1',
app: {
id: 'real-app-id',
name: 'Integration Test App',
mode,
icon_type: 'emoji',
icon: '🧪',
icon_background: '#FFFFFF',
icon_url: '',
description: 'Test app for integration',
use_icon_as_answer_icon: false,
},
uninstallable: true,
is_pinned: false,
})
const mockAppParams = {
user_input_form: [],
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
system_parameters: {},
}
type MockOverrides = {
installedApps?: { apps?: InstalledAppModel[]; isPending?: boolean; isFetching?: boolean }
accessMode?: { isPending?: boolean; data?: unknown; error?: unknown }
params?: { isPending?: boolean; data?: unknown; error?: unknown }
meta?: { isPending?: boolean; data?: unknown; error?: unknown }
userAccess?: { data?: unknown; error?: unknown }
}
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
const installedApps = overrides.installedApps?.apps ?? (app ? [app] : [])
;(useGetInstalledApps as Mock).mockReturnValue({
data: { installed_apps: installedApps },
isPending: false,
isFetching: false,
...overrides.installedApps,
})
;(useWebAppStore as unknown as Mock).mockImplementation(
(selector: (state: Record<string, Mock>) => unknown) => {
return selector({
updateAppInfo: mockUpdateAppInfo,
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
updateAppParams: mockUpdateAppParams,
updateWebAppMeta: mockUpdateWebAppMeta,
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
})
},
)
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isPending: false,
data: { accessMode: AccessMode.PUBLIC },
error: null,
...overrides.accessMode,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isPending: false,
data: mockAppParams,
error: null,
...overrides.params,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isPending: false,
data: { tool_icons: {} },
error: null,
...overrides.meta,
})
;(useGetUserCanAccessApp as Mock).mockReturnValue({
data: { result: true },
error: null,
...overrides.userAccess,
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Mode-Based Routing', () => {
it.each([
[AppModeEnum.CHAT, 'chat-with-history'],
[AppModeEnum.ADVANCED_CHAT, 'chat-with-history'],
[AppModeEnum.AGENT_CHAT, 'chat-with-history'],
])('should render ChatWithHistory for %s mode', async (mode, testId) => {
const app = createInstalledApp(mode)
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(await screen.findByTestId(testId)).toBeInTheDocument()
expect(screen.getByText(/Integration Test App/)).toBeInTheDocument()
})
it('should render TextGenerationApp for COMPLETION mode', () => {
const app = createInstalledApp(AppModeEnum.COMPLETION)
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText('Text Generation')).toBeInTheDocument()
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
})
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
const app = createInstalledApp(AppModeEnum.WORKFLOW)
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
})
})
describe('Data Loading Flow', () => {
it('should show loading spinner when params are being fetched', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { isPending: true, data: null } })
const { container } = render(<InstalledApp id="installed-app-1" />)
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
})
it('should defer 404 while installed apps are refetching without a match', () => {
setupDefaultMocks(undefined, {
installedApps: { apps: [], isPending: false, isFetching: true },
})
const { container } = render(<InstalledApp id="nonexistent" />)
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should render content when all data is available', () => {
const app = createInstalledApp()
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
})
})
describe('Error Handling Flow', () => {
it('should show error state when API fails', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } })
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByText(/Network error/)).toBeInTheDocument()
})
it('should show 404 when app is not found', () => {
setupDefaultMocks(undefined, {
accessMode: { data: null },
params: { data: null },
meta: { data: null },
userAccess: { data: null },
})
render(<InstalledApp id="nonexistent" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should show 403 when user has no permission', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { userAccess: { data: { result: false } } })
render(<InstalledApp id="installed-app-1" />)
expect(screen.getByText(/403/)).toBeInTheDocument()
})
})
describe('State Synchronization', () => {
it('should update all stores when app data is loaded', async () => {
const app = createInstalledApp()
setupDefaultMocks(app)
render(<InstalledApp id="installed-app-1" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
app_id: 'installed-app-1',
site: expect.objectContaining({
title: 'Integration Test App',
icon: '🧪',
}),
}),
)
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} })
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
})
})
})
})
@@ -1,218 +0,0 @@
/**
* Integration test: Sidebar Lifecycle Flow
*
* Tests the sidebar interactions for installed apps lifecycle:
* navigation, pin/unpin ordering, delete confirmation, and
* fold/unfold behavior.
*/
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SideBar from '@/app/components/explore/sidebar'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
const { mockToastSuccess } = vi.hoisted(() => ({
mockToastSuccess: vi.fn(),
}))
let mockMediaType: string = MediaType.pc
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockInstalledApps: InstalledApp[] = []
let mockIsUninstallPending = false
vi.mock('@/next/navigation', () => ({
usePathname: () => '/explore',
useSelectedLayoutSegments: () => mockSegments,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => mockMediaType,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isPending: false,
data: { installed_apps: mockInstalledApps },
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
isPending: mockIsUninstallPending,
}),
useUpdateAppPinStatus: () => ({
mutateAsync: mockUpdatePinStatus,
}),
}))
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
success: mockToastSuccess,
},
}
})
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'app-1',
uninstallable: overrides.uninstallable ?? false,
is_pinned: overrides.is_pinned ?? false,
app: {
id: overrides.app?.id ?? 'app-basic-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '🤖',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'App One',
description: overrides.app?.description ?? 'desc',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
})
const renderSidebar = () => {
return render(<SideBar />)
}
describe('Sidebar Lifecycle Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMediaType = MediaType.pc
mockInstalledApps = []
mockIsUninstallPending = false
})
describe('Pin / Unpin / Delete Flow', () => {
it('should complete pin → unpin cycle for an app', async () => {
mockUpdatePinStatus.mockResolvedValue(undefined)
// Step 1: Start with an unpinned app and pin it
const unpinnedApp = createInstalledApp({ is_pinned: false })
mockInstalledApps = [unpinnedApp]
const { unmount } = renderSidebar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true })
expect(mockToastSuccess).toHaveBeenCalled()
})
// Step 2: Simulate refetch returning pinned state, then unpin
unmount()
vi.clearAllMocks()
mockUpdatePinStatus.mockResolvedValue(undefined)
const pinnedApp = createInstalledApp({ is_pinned: true })
mockInstalledApps = [pinnedApp]
renderSidebar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false })
expect(mockToastSuccess).toHaveBeenCalled()
})
})
it('should complete the delete flow with confirmation', async () => {
const app = createInstalledApp()
mockInstalledApps = [app]
mockUninstall.mockResolvedValue(undefined)
renderSidebar()
// Step 1: Open operation menu and click delete
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
// Step 2: Confirm dialog appears
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
// Step 3: Confirm deletion
fireEvent.click(screen.getByText('common.operation.confirm'))
// Step 4: Uninstall API called and success toast shown
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('app-1')
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
})
})
it('should cancel deletion when user clicks cancel', async () => {
const app = createInstalledApp()
mockInstalledApps = [app]
renderSidebar()
// Open delete flow
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
// Cancel the deletion
fireEvent.click(await screen.findByText('common.operation.cancel'))
// Uninstall should not be called
expect(mockUninstall).not.toHaveBeenCalled()
})
})
describe('Multi-App Ordering', () => {
it('should display pinned apps before unpinned apps with divider', () => {
mockInstalledApps = [
createInstalledApp({
id: 'pinned-1',
is_pinned: true,
app: { ...createInstalledApp().app, name: 'Pinned App' },
}),
createInstalledApp({
id: 'unpinned-1',
is_pinned: false,
app: { ...createInstalledApp().app, name: 'Regular App' },
}),
]
const { container } = renderSidebar()
// Both apps are rendered
const pinnedApp = screen.getByText('Pinned App')
const regularApp = screen.getByText('Regular App')
expect(pinnedApp).toBeInTheDocument()
expect(regularApp).toBeInTheDocument()
// Pinned app appears before unpinned app in the DOM
const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')!
const regularItem = regularApp.closest('[class*="rounded-lg"]')!
expect(
pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
// Divider is rendered between pinned and unpinned sections
const divider = container.querySelector('[class*="bg-divider-regular"]')
expect(divider).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('should show NoApps component when no apps are installed on desktop', () => {
mockMediaType = MediaType.pc
renderSidebar()
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
})
})
})
@@ -1,135 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
// Type alias for search mode
type SearchMode = 'scopes' | 'commands' | null
// Mock component to test tag display logic
const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => {
if (!searchMode) return null
return (
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span>{searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'}</span>
</div>
)
}
describe('Scope and Command Tags', () => {
describe('Tag Display Logic', () => {
it('should display SCOPES for @ actions', () => {
render(<TagDisplay searchMode="scopes" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
})
it('should display COMMANDS for / actions', () => {
render(<TagDisplay searchMode="commands" />)
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
})
it('should not display any tag when searchMode is null', () => {
const { container } = render(<TagDisplay searchMode={null} />)
expect(container.firstChild).toBeNull()
})
})
describe('Search Mode Detection', () => {
const getSearchMode = (query: string): SearchMode => {
if (query.startsWith('@')) return 'scopes'
if (query.startsWith('/')) return 'commands'
return null
}
it('should detect scopes mode for @ queries', () => {
expect(getSearchMode('@app')).toBe('scopes')
expect(getSearchMode('@knowledge')).toBe('scopes')
expect(getSearchMode('@plugin')).toBe('scopes')
expect(getSearchMode('@node')).toBe('scopes')
})
it('should detect commands mode for / queries', () => {
expect(getSearchMode('/theme')).toBe('commands')
expect(getSearchMode('/language')).toBe('commands')
expect(getSearchMode('/docs')).toBe('commands')
})
it('should return null for regular queries', () => {
expect(getSearchMode('')).toBe(null)
expect(getSearchMode('search term')).toBe(null)
expect(getSearchMode('app')).toBe(null)
})
it('should handle queries with spaces', () => {
expect(getSearchMode('@app search')).toBe('scopes')
expect(getSearchMode('/theme dark')).toBe('commands')
})
})
describe('Tag Styling', () => {
it('should apply correct styling classes', () => {
const { container } = render(<TagDisplay searchMode="scopes" />)
const tagContainer = container.querySelector(
'.flex.items-center.gap-1.text-xs.text-text-tertiary',
)
expect(tagContainer).toBeInTheDocument()
})
it('should use hardcoded English text', () => {
// Verify that tags are hardcoded and not using i18n
render(<TagDisplay searchMode="scopes" />)
const scopesText = screen.getByText('SCOPES')
expect(scopesText.textContent).toBe('SCOPES')
render(<TagDisplay searchMode="commands" />)
const commandsText = screen.getByText('COMMANDS')
expect(commandsText.textContent).toBe('COMMANDS')
})
})
describe('Integration with Search States', () => {
const SearchComponent: React.FC<{ query: string }> = ({ query }) => {
let searchMode: SearchMode = null
if (query.startsWith('@')) searchMode = 'scopes'
else if (query.startsWith('/')) searchMode = 'commands'
return (
<div>
<input value={query} readOnly />
<TagDisplay searchMode={searchMode} />
</div>
)
}
it('should update tag when switching between @ and /', () => {
const { rerender } = render(<SearchComponent query="@app" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="/theme" />)
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
})
it('should hide tag when clearing search', () => {
const { rerender } = render(<SearchComponent query="@app" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="" />)
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
})
it('should maintain correct tag during search refinement', () => {
const { rerender } = render(<SearchComponent query="@" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="@app" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="@app test" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
})
})
})
@@ -1,211 +0,0 @@
import type { SlashCommandHandler } from '../../app/components/goto-anything/actions/commands/types'
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
// Mock the registry
vi.mock('../../app/components/goto-anything/actions/commands/registry')
describe('Slash Command Dual-Mode System', () => {
const mockDirectCommand: SlashCommandHandler = {
name: 'docs',
description: 'Open documentation',
mode: 'direct',
execute: vi.fn(),
search: vi.fn().mockResolvedValue([
{
id: 'docs',
title: 'Documentation',
description: 'Open documentation',
type: 'command' as const,
data: { command: 'navigation.docs', args: {} },
},
]),
register: vi.fn(),
unregister: vi.fn(),
}
const mockSubmenuCommand: SlashCommandHandler = {
name: 'theme',
description: 'Change theme',
mode: 'submenu',
search: vi.fn().mockResolvedValue([
{
id: 'theme-light',
title: 'Light Theme',
description: 'Switch to light theme',
type: 'command' as const,
data: { command: 'theme.set', args: { theme: 'light' } },
},
{
id: 'theme-dark',
title: 'Dark Theme',
description: 'Switch to dark theme',
type: 'command' as const,
data: { command: 'theme.set', args: { theme: 'dark' } },
},
]),
register: vi.fn(),
unregister: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => {
if (name === 'docs') return mockDirectCommand
if (name === 'theme') return mockSubmenuCommand
return undefined
})
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
mockDirectCommand,
mockSubmenuCommand,
])
})
describe('Direct Mode Commands', () => {
it('should execute immediately when selected', () => {
const mockSetShow = vi.fn()
const mockSetSearchQuery = vi.fn()
// Simulate command selection
const handler = slashCommandRegistry.findCommand('docs')
expect(handler?.mode).toBe('direct')
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
mockSetShow(false)
mockSetSearchQuery('')
}
expect(mockDirectCommand.execute).toHaveBeenCalled()
expect(mockSetShow).toHaveBeenCalledWith(false)
expect(mockSetSearchQuery).toHaveBeenCalledWith('')
})
it('should not enter submenu for direct mode commands', () => {
const handler = slashCommandRegistry.findCommand('docs')
expect(handler?.mode).toBe('direct')
expect(handler?.execute).toBeDefined()
})
it('should close modal after execution', () => {
const mockModalClose = vi.fn()
const handler = slashCommandRegistry.findCommand('docs')
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
mockModalClose()
}
expect(mockModalClose).toHaveBeenCalled()
})
})
describe('Submenu Mode Commands', () => {
it('should show options instead of executing immediately', async () => {
const handler = slashCommandRegistry.findCommand('theme')
expect(handler?.mode).toBe('submenu')
const results = await handler?.search('', 'en')
expect(results).toHaveLength(2)
expect(results?.[0]!.title).toBe('Light Theme')
expect(results?.[1]!.title).toBe('Dark Theme')
})
it('should not have execute function for submenu mode', () => {
const handler = slashCommandRegistry.findCommand('theme')
expect(handler?.mode).toBe('submenu')
expect(handler?.execute).toBeUndefined()
})
it('should keep modal open for selection', () => {
const mockModalClose = vi.fn()
const handler = slashCommandRegistry.findCommand('theme')
// For submenu mode, modal should not close immediately
expect(handler?.mode).toBe('submenu')
expect(mockModalClose).not.toHaveBeenCalled()
})
})
describe('Mode Detection and Routing', () => {
it('should correctly identify direct mode commands', () => {
const commands = slashCommandRegistry.getAllCommands()
const directCommands = commands.filter((cmd) => cmd.mode === 'direct')
const submenuCommands = commands.filter((cmd) => cmd.mode === 'submenu')
expect(directCommands).toContainEqual(expect.objectContaining({ name: 'docs' }))
expect(submenuCommands).toContainEqual(expect.objectContaining({ name: 'theme' }))
})
it('should handle missing mode property gracefully', () => {
const commandWithoutMode: SlashCommandHandler = {
name: 'test',
description: 'Test command',
search: vi.fn(),
register: vi.fn(),
unregister: vi.fn(),
}
vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode)
const handler = slashCommandRegistry.findCommand('test')
// Default behavior should be submenu when mode is not specified
expect(handler?.mode).toBeUndefined()
expect(handler?.execute).toBeUndefined()
})
})
describe('Enter Key Handling', () => {
// Helper function to simulate key handler behavior
const createKeyHandler = () => {
return (commandKey: string) => {
if (commandKey.startsWith('/')) {
const commandName = commandKey.substring(1)
const handler = slashCommandRegistry.findCommand(commandName)
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
return true // Indicates handled
}
}
return false
}
}
it('should trigger direct execution on Enter for direct mode', () => {
const keyHandler = createKeyHandler()
const handled = keyHandler('/docs')
expect(handled).toBe(true)
expect(mockDirectCommand.execute).toHaveBeenCalled()
})
it('should not trigger direct execution for submenu mode', () => {
const keyHandler = createKeyHandler()
const handled = keyHandler('/theme')
expect(handled).toBe(false)
expect(mockSubmenuCommand.search).not.toHaveBeenCalled()
})
})
describe('Command Registration', () => {
it('should register both direct and submenu commands', () => {
mockDirectCommand.register?.({})
mockSubmenuCommand.register?.({ setTheme: vi.fn() })
expect(mockDirectCommand.register).toHaveBeenCalled()
expect(mockSubmenuCommand.register).toHaveBeenCalled()
})
it('should handle unregistration for both command types', () => {
// Test unregister for direct command
mockDirectCommand.unregister?.()
expect(mockDirectCommand.unregister).toHaveBeenCalled()
// Test unregister for submenu command
mockSubmenuCommand.unregister?.()
expect(mockSubmenuCommand.unregister).toHaveBeenCalled()
// Verify both were called independently
expect(mockDirectCommand.unregister).toHaveBeenCalledTimes(1)
expect(mockSubmenuCommand.unregister).toHaveBeenCalledTimes(1)
})
})
})
@@ -1,198 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type'
import AccountDropdown from '@/app/components/header/account-dropdown'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
const { mockAppContextState, mockPush, mockLogout, mockResetUser, mockSetShowAccountSettingModal } =
vi.hoisted(() => ({
mockAppContextState: {
userProfile: {
id: 'user-1',
name: 'Ada Lovelace',
email: 'ada@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
langGeniusVersionInfo: {
current_env: 'CLOUD',
current_version: '1.0.0',
latest_version: '1.1.0',
release_date: '',
release_notes: 'https://example.com/releases/1.1.0',
version: '1.0.0',
can_auto_update: false,
},
isCurrentWorkspaceOwner: false,
},
mockPush: vi.fn(),
mockLogout: vi.fn(),
mockResetUser: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string, options?: { ns?: string; version?: string }) => {
if (options?.version) return `${options.ns}.${key}:${options.version}`
return options?.ns ? `${options.ns}.${key}` : key
}),
}),
}
})
vi.mock('@/context/account-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('@/context/workspace-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('@/context/permission-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('@/context/version-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('@/context/system-features-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } =
await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isEducationAccount: false,
plan: {
type: Plan.professional,
},
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/service/use-common', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/use-common')>()),
useLogout: () => ({
mutateAsync: mockLogout,
}),
}))
vi.mock('@/app/components/base/amplitude/utils', () => ({
resetUser: mockResetUser,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
...props
}: {
href: string
children?: React.ReactNode
} & Record<string, unknown>) => (
<a href={href} {...props}>
{children}
</a>
),
}))
const renderAccountDropdown = () => {
return renderWithSystemFeatures(<AccountDropdown />, {
systemFeatures: {
branding: {
enabled: false,
workspace_logo: '',
},
},
})
}
describe('Header Account Dropdown Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
repo: { stars: 123456 },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
)
localStorage.clear()
})
it('opens account actions, fetches github stars, and opens the settings and about flows', async () => {
renderAccountDropdown()
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
expect(screen.getByText('ada@example.com')).toBeInTheDocument()
expect(await screen.findByText('123,456')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.userProfile.settings'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.MEMBERS,
})
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(screen.getByText('common.userProfile.about'))
await waitFor(() => {
expect(screen.getByText(/Version/)).toBeInTheDocument()
expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument()
})
})
it('logs out, resets cached user markers, and redirects to signin', async () => {
localStorage.setItem('education-reverify-prev-expire-at', '1')
localStorage.setItem('education-reverify-has-noticed', '1')
localStorage.setItem('education-expired-has-noticed', '1')
renderAccountDropdown()
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(screen.getByText('common.userProfile.logout'))
await waitFor(() => {
expect(mockLogout).toHaveBeenCalledTimes(1)
expect(mockResetUser).toHaveBeenCalledTimes(1)
expect(mockPush).toHaveBeenCalledWith('/signin')
})
expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull()
expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull()
expect(localStorage.getItem('education-expired-has-noticed')).toBeNull()
})
})
@@ -1,207 +0,0 @@
/**
* Test cases to reproduce the plugin tool workflow error
* Issue: #23154 - Application error when loading plugin tools in workflow
* Root cause: split() operation called on null/undefined values
*/
describe('Plugin Tool Workflow Error Reproduction', () => {
/**
* Mock function to simulate the problematic code in switch-plugin-version.tsx:29
* const [pluginId] = uniqueIdentifier.split(':')
*/
const mockSwitchPluginVersionLogic = (uniqueIdentifier: string | null | undefined) => {
// This directly reproduces the problematic line from switch-plugin-version.tsx:29
const [pluginId] = uniqueIdentifier!.split(':')
return pluginId
}
/**
* Test case 1: Simulate null uniqueIdentifier
* This should reproduce the error mentioned in the issue
*/
it('should reproduce error when uniqueIdentifier is null', () => {
expect(() => {
mockSwitchPluginVersionLogic(null)
}).toThrow("Cannot read properties of null (reading 'split')")
})
/**
* Test case 2: Simulate undefined uniqueIdentifier
*/
it('should reproduce error when uniqueIdentifier is undefined', () => {
expect(() => {
mockSwitchPluginVersionLogic(undefined)
}).toThrow("Cannot read properties of undefined (reading 'split')")
})
/**
* Test case 3: Simulate empty string uniqueIdentifier
*/
it('should handle empty string uniqueIdentifier', () => {
expect(() => {
const result = mockSwitchPluginVersionLogic('')
expect(result).toBe('') // Empty string split by ':' returns ['']
}).not.toThrow()
})
/**
* Test case 4: Simulate malformed uniqueIdentifier without colon separator
*/
it('should handle malformed uniqueIdentifier without colon separator', () => {
expect(() => {
const result = mockSwitchPluginVersionLogic('malformed-identifier-without-colon')
expect(result).toBe('malformed-identifier-without-colon') // No colon means full string returned
}).not.toThrow()
})
/**
* Test case 5: Simulate valid uniqueIdentifier
*/
it('should work correctly with valid uniqueIdentifier', () => {
expect(() => {
const result = mockSwitchPluginVersionLogic('valid-plugin-id:1.0.0')
expect(result).toBe('valid-plugin-id')
}).not.toThrow()
})
})
/**
* Test for the variable processing split error in use-single-run-form-params
*/
describe('Variable Processing Split Error', () => {
/**
* Mock function to simulate the problematic code in use-single-run-form-params.ts:91
* const getDependentVars = () => {
* return varInputs.map(item => item.variable.slice(1, -1).split('.'))
* }
*/
const mockGetDependentVars = (varInputs: Array<{ variable: string | null | undefined }>) => {
return varInputs
.map((item) => {
// Guard against null/undefined variable to prevent app crash
if (!item.variable || typeof item.variable !== 'string') return []
return item.variable.slice(1, -1).split('.')
})
.filter((arr) => arr.length > 0) // Filter out empty arrays
}
/**
* Test case 1: Variable processing with null variable
*/
it('should handle null variable safely', () => {
const varInputs = [{ variable: null }]
expect(() => {
mockGetDependentVars(varInputs)
}).not.toThrow()
const result = mockGetDependentVars(varInputs)
expect(result).toEqual([]) // null variables are filtered out
})
/**
* Test case 2: Variable processing with undefined variable
*/
it('should handle undefined variable safely', () => {
const varInputs = [{ variable: undefined }]
expect(() => {
mockGetDependentVars(varInputs)
}).not.toThrow()
const result = mockGetDependentVars(varInputs)
expect(result).toEqual([]) // undefined variables are filtered out
})
/**
* Test case 3: Variable processing with empty string
*/
it('should handle empty string variable', () => {
const varInputs = [{ variable: '' }]
expect(() => {
mockGetDependentVars(varInputs)
}).not.toThrow()
const result = mockGetDependentVars(varInputs)
expect(result).toEqual([]) // Empty string is filtered out, so result is empty array
})
/**
* Test case 4: Variable processing with valid variable format
*/
it('should work correctly with valid variable format', () => {
const varInputs = [{ variable: '{{workflow.node.output}}' }]
expect(() => {
mockGetDependentVars(varInputs)
}).not.toThrow()
const result = mockGetDependentVars(varInputs)
expect(result[0]).toEqual(['{workflow', 'node', 'output}'])
})
})
/**
* Integration test to simulate the complete workflow scenario
*/
describe('Plugin Tool Workflow Integration', () => {
/**
* Simulate the scenario where plugin metadata is incomplete or corrupted
* This can happen when:
* 1. Plugin is being loaded from marketplace but metadata request fails
* 2. Plugin configuration is corrupted in database
* 3. Network issues during plugin loading
*/
it('should reproduce the client-side exception scenario', () => {
// Mock incomplete plugin data that could cause the error
const incompletePluginData = {
// Missing or null uniqueIdentifier
uniqueIdentifier: null,
meta: null,
minimum_dify_version: undefined,
}
// This simulates the error path that leads to the white screen
expect(() => {
// Simulate the code path in switch-plugin-version.tsx:29
// The actual problematic code doesn't use optional chaining
const _pluginId = (incompletePluginData.uniqueIdentifier as any).split(':')[0]
}).toThrow("Cannot read properties of null (reading 'split')")
})
/**
* Test the scenario mentioned in the issue where plugin tools are loaded in workflow
*/
it('should simulate plugin tool loading in workflow context', () => {
// Mock the workflow context where plugin tools are being loaded
const workflowPluginTools = [
{
provider_name: 'test-plugin',
uniqueIdentifier: null, // This is the problematic case
tool_name: 'test-tool',
},
{
provider_name: 'valid-plugin',
uniqueIdentifier: 'valid-plugin:1.0.0',
tool_name: 'valid-tool',
},
]
// Process each plugin tool
workflowPluginTools.forEach((tool, _index) => {
if (tool.uniqueIdentifier === null) {
// This reproduces the exact error scenario
expect(() => {
const _pluginId = (tool.uniqueIdentifier as any).split(':')[0]
}).toThrow()
} else {
// Valid tools should work fine
expect(() => {
const _pluginId = tool.uniqueIdentifier.split(':')[0]
}).not.toThrow()
}
})
})
})
@@ -1,264 +0,0 @@
/**
* Integration Test: Plugin Authentication Flow
*
* Tests the integration between PluginAuth, usePluginAuth hook,
* Authorize/Authorized components, and credential management.
* Verifies the complete auth flow from checking authorization status
* to rendering the correct UI state.
*/
import { cleanup, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
vi.mock('react-i18next', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
t: withSelectorKey((key: string) => {
const map: Record<string, string> = {
'plugin.auth.setUpTip': 'Set up your credentials',
'plugin.auth.authorized': 'Authorized',
'plugin.auth.apiKey': 'API Key',
'plugin.auth.oauth': 'OAuth',
}
return map[key] ?? key
}),
}),
}
})
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
const mockUsePluginAuth = vi.fn()
vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({
usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args),
}))
vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({
default: ({
pluginPayload,
canOAuth,
canApiKey,
}: {
pluginPayload: { provider: string }
canOAuth: boolean
canApiKey: boolean
}) => (
<div data-testid="authorize-component">
<span data-testid="auth-provider">{pluginPayload.provider}</span>
{canOAuth && <span data-testid="auth-oauth">OAuth available</span>}
{canApiKey && <span data-testid="auth-apikey">API Key available</span>}
</div>
),
}))
vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({
default: ({
pluginPayload,
credentials,
}: {
pluginPayload: { provider: string }
credentials: Array<{ id: string; name: string }>
}) => (
<div data-testid="authorized-component">
<span data-testid="auth-provider">{pluginPayload.provider}</span>
<span data-testid="auth-credential-count">{credentials.length} credentials</span>
</div>
),
}))
const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth')
describe('Plugin Authentication Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
})
const basePayload = {
category: AuthCategory.tool,
provider: 'test-provider',
}
describe('Unauthorized State', () => {
it('renders Authorize component when not authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
canApiKey: true,
credentials: [],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.getByTestId('authorize-component')).toBeInTheDocument()
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
})
it('shows OAuth option when plugin supports it', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: true,
canApiKey: true,
credentials: [],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
})
it('applies className to wrapper when not authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
canApiKey: true,
credentials: [],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const { container } = render(
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('Authorized State', () => {
it('renders Authorized component when authorized and no children', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
canApiKey: true,
credentials: [{ id: 'cred-1', name: 'My API Key', is_default: true }],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
expect(screen.getByTestId('authorized-component')).toBeInTheDocument()
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials')
})
it('renders children instead of Authorized when authorized and children provided', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
canApiKey: true,
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(
<PluginAuth pluginPayload={basePayload}>
<div data-testid="custom-children">Custom authorized view</div>
</PluginAuth>,
)
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
expect(screen.getByTestId('custom-children')).toBeInTheDocument()
})
it('does not apply className when authorized', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: false,
canApiKey: true,
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const { container } = render(
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
)
expect(container.firstChild).not.toHaveClass('custom-class')
})
})
describe('Auth Category Integration', () => {
it('passes correct provider to usePluginAuth for tool category', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: false,
canApiKey: true,
credentials: [],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const toolPayload = {
category: AuthCategory.tool,
provider: 'google-search-provider',
}
render(<PluginAuth pluginPayload={toolPayload} />)
expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true)
expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider')
})
it('passes correct provider to usePluginAuth for datasource category', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: false,
canOAuth: true,
canApiKey: false,
credentials: [],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
const dsPayload = {
category: AuthCategory.datasource,
provider: 'notion-datasource',
}
render(<PluginAuth pluginPayload={dsPayload} />)
expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true)
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument()
})
})
describe('Multiple Credentials', () => {
it('shows credential count when multiple credentials exist', () => {
mockUsePluginAuth.mockReturnValue({
isAuthorized: true,
canOAuth: true,
canApiKey: true,
credentials: [
{ id: 'cred-1', name: 'API Key 1', is_default: true },
{ id: 'cred-2', name: 'API Key 2', is_default: false },
{
id: 'cred-3',
name: 'OAuth Token',
is_default: false,
credential_type: CredentialTypeEnum.OAUTH2,
},
],
invalidPluginCredentialInfo: vi.fn(),
notAllowCustomCredential: false,
})
render(<PluginAuth pluginPayload={basePayload} />)
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials')
})
})
})
@@ -1,208 +0,0 @@
/**
* Integration Test: Plugin Card Rendering Pipeline
*
* Tests the integration between Card, Icon, Title, Description,
* OrgInfo, CornerMark, and CardMoreInfo components. Verifies that
* plugin data flows correctly through the card rendering pipeline.
*/
import { cleanup, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let mockTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
}))
vi.mock('@/types/app', async () => {
return vi.importActual<typeof import('@/types/app')>('@/types/app')
})
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: unknown[]) => args.filter((a) => typeof a === 'string' && a).join(' '),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useCategories: () => ({
categoriesMap: {
tool: { label: 'Tool' },
model: { label: 'Model' },
extension: { label: 'Extension' },
},
}),
}))
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
default: () => <span data-testid="partner-badge">Partner</span>,
}))
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
default: () => <span data-testid="verified-badge">Verified</span>,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({
src,
installed,
installFailed,
}: {
src: string | object
installed?: boolean
installFailed?: boolean
}) => (
<div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}>
{typeof src === 'string' ? src : 'emoji-icon'}
</div>
),
}))
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
default: ({ text }: { text: string }) => <div data-testid="corner-mark">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text, descriptionLineRows }: { text: string; descriptionLineRows?: number }) => (
<div data-testid="description" data-rows={descriptionLineRows}>
{text}
</div>
),
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName, packageName }: { orgName: string; packageName: string }) => (
<div data-testid="org-info">
{orgName}/{packageName}
</div>
),
}))
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
default: ({ text }: { text: string }) => <div data-testid="placeholder">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
const { default: Card } = await import('@/app/components/plugins/card/index')
type CardPayload = Parameters<typeof Card>[0]['payload']
describe('Plugin Card Rendering Integration', () => {
beforeEach(() => {
cleanup()
mockTheme = 'light'
})
const makePayload = (overrides = {}) =>
({
category: 'tool',
type: 'plugin',
name: 'google-search',
org: 'langgenius',
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' },
icon: 'https://example.com/icon.png',
verified: true,
badges: [] as string[],
...overrides,
}) as CardPayload
it('renders a complete plugin card with all subcomponents', () => {
const payload = makePayload()
render(<Card payload={payload} />)
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
})
it('shows corner mark with category label when not hidden', () => {
const payload = makePayload()
render(<Card payload={payload} />)
expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
})
it('hides corner mark when hideCornerMark is true', () => {
const payload = makePayload()
render(<Card payload={payload} hideCornerMark />)
expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument()
})
it('shows installed status on icon', () => {
const payload = makePayload()
render(<Card payload={payload} installed />)
const icon = screen.getByTestId('card-icon')
expect(icon).toHaveAttribute('data-installed', 'true')
})
it('shows install failed status on icon', () => {
const payload = makePayload()
render(<Card payload={payload} installFailed />)
const icon = screen.getByTestId('card-icon')
expect(icon).toHaveAttribute('data-install-failed', 'true')
})
it('renders verified badge when plugin is verified', () => {
const payload = makePayload({ verified: true })
render(<Card payload={payload} />)
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
})
it('renders partner badge when plugin has partner badge', () => {
const payload = makePayload({ badges: ['partner'] })
render(<Card payload={payload} />)
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
})
it('renders footer content when provided', () => {
const payload = makePayload()
render(<Card payload={payload} footer={<div data-testid="custom-footer">Custom footer</div>} />)
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
})
it('renders titleLeft content when provided', () => {
const payload = makePayload()
render(<Card payload={payload} titleLeft={<span data-testid="title-left-content">New</span>} />)
expect(screen.getByTestId('title-left-content')).toBeInTheDocument()
})
it('uses dark icon when theme is dark and icon_dark is provided', () => {
mockTheme = 'dark'
const payload = makePayload({
icon: 'https://example.com/icon-light.png',
icon_dark: 'https://example.com/icon-dark.png',
})
render(<Card payload={payload} />)
expect(screen.getByTestId('card-icon')).toHaveTextContent('https://example.com/icon-dark.png')
})
it('shows loading placeholder when isLoading is true', () => {
const payload = makePayload()
render(<Card payload={payload} isLoading loadingFileName="uploading.difypkg" />)
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
})
it('renders description with custom line rows', () => {
const payload = makePayload()
render(<Card payload={payload} descriptionLineRows={3} />)
const description = screen.getByTestId('description')
expect(description).toHaveAttribute('data-rows', '3')
})
})
@@ -1,239 +0,0 @@
/**
* Integration Test: Plugin Installation Flow
*
* Tests the integration between GitHub release fetching, version comparison,
* upload handling, and task status polling. Verifies the complete plugin
* installation pipeline from source discovery to completion.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkForUpdates,
fetchReleases,
handleUpload,
} from '@/app/components/plugins/install-plugin/hooks'
const mockToastNotify = vi.fn()
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: Object.assign(
(message: string, options?: { type?: string }) =>
mockToastNotify({ type: options?.type, message }),
{
success: (message: string) => mockToastNotify({ type: 'success', message }),
error: (message: string) => mockToastNotify({ type: 'error', message }),
warning: (message: string) => mockToastNotify({ type: 'warning', message }),
info: (message: string) => mockToastNotify({ type: 'info', message }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
},
),
}))
const mockUploadGitHub = vi.fn()
vi.mock('@/service/plugins', () => ({
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
checkTaskStatus: vi.fn(),
}))
describe('Plugin Installation Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.fetch = vi.fn()
})
describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => {
it('fetches releases, checks for updates, and uploads the new version', async () => {
const mockReleases = [
{
tag: 'v2.0.0',
assets: [{ downloadUrl: 'https://github.com/test/v2.difypkg' }],
},
{
tag: 'v1.5.0',
assets: [{ downloadUrl: 'https://github.com/test/v1.5.difypkg' }],
},
{
tag: 'v1.0.0',
assets: [{ downloadUrl: 'https://github.com/test/v1.difypkg' }],
},
]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ releases: mockReleases }),
})
mockUploadGitHub.mockResolvedValue({
manifest: { name: 'test-plugin', version: '2.0.0' },
unique_identifier: 'test-plugin:2.0.0',
})
const releases = await fetchReleases('test-org', 'test-repo')
expect(releases).toHaveLength(3)
expect(releases[0].tag_name).toBe('v2.0.0')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(true)
expect(toastProps.message).toContain('v2.0.0')
const onSuccess = vi.fn()
const result = await handleUpload(
'https://github.com/test-org/test-repo',
'v2.0.0',
'plugin-v2.difypkg',
onSuccess,
)
expect(mockUploadGitHub).toHaveBeenCalledWith(
'https://github.com/test-org/test-repo',
'v2.0.0',
'plugin-v2.difypkg',
)
expect(onSuccess).toHaveBeenCalledWith({
manifest: { name: 'test-plugin', version: '2.0.0' },
unique_identifier: 'test-plugin:2.0.0',
})
expect(result).toEqual({
manifest: { name: 'test-plugin', version: '2.0.0' },
unique_identifier: 'test-plugin:2.0.0',
})
})
it('handles no new version available', async () => {
const mockReleases = [
{
tag: 'v1.0.0',
assets: [{ downloadUrl: 'https://github.com/test/v1.difypkg' }],
},
]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ releases: mockReleases }),
})
const releases = await fetchReleases('test-org', 'test-repo')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('info')
expect(toastProps.message).toBe('No new version available')
})
it('handles empty releases', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ releases: [] }),
})
const releases = await fetchReleases('test-org', 'test-repo')
expect(releases).toHaveLength(0)
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('error')
expect(toastProps.message).toBe('Input releases is empty')
})
it('handles fetch failure gracefully', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 404,
})
const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo')
expect(releases).toEqual([])
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('handles upload failure gracefully', async () => {
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
const onSuccess = vi.fn()
await expect(
handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess),
).rejects.toThrow('Upload failed')
expect(onSuccess).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
)
})
})
describe('Task Status Polling Integration', () => {
it('polls until plugin installation succeeds', async () => {
const mockCheckTaskStatus = vi
.fn()
.mockResolvedValueOnce({
task: {
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }],
},
})
.mockResolvedValueOnce({
task: {
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }],
},
})
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
await vi.doMock('@/utils', () => ({
sleep: () => Promise.resolve(),
}))
const { default: checkTaskStatus } =
await import('@/app/components/plugins/install-plugin/base/check-task-status')
const checker = checkTaskStatus()
const result = await checker.check({
taskId: 'task-123',
pluginUniqueIdentifier: 'test:1.0.0',
})
expect(result.status).toBe('success')
})
it('returns failure when plugin not found in task', async () => {
const mockCheckTaskStatus = vi.fn().mockResolvedValue({
task: {
plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }],
},
})
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
const { default: checkTaskStatus } =
await import('@/app/components/plugins/install-plugin/base/check-task-status')
const checker = checkTaskStatus()
const result = await checker.check({
taskId: 'task-123',
pluginUniqueIdentifier: 'test:1.0.0',
})
expect(result.status).toBe('failed')
expect(result.error).toBe('Plugin package not found')
})
it('stops polling when stop() is called', async () => {
const { default: checkTaskStatus } =
await import('@/app/components/plugins/install-plugin/base/check-task-status')
const checker = checkTaskStatus()
checker.stop()
const result = await checker.check({
taskId: 'task-123',
pluginUniqueIdentifier: 'test:1.0.0',
})
expect(result.status).toBe('success')
})
})
})
@@ -1,106 +0,0 @@
import { describe, expect, it } from 'vitest'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
import { InstallationScope } from '@/features/system-features/constants'
describe('Plugin Marketplace to Install Flow', () => {
describe('install permission validation pipeline', () => {
const systemFeaturesAll = {
plugin_installation_permission: {
restrict_to_marketplace_only: false,
plugin_installation_scope: InstallationScope.ALL,
},
}
const systemFeaturesMarketplaceOnly = {
plugin_installation_permission: {
restrict_to_marketplace_only: true,
plugin_installation_scope: InstallationScope.ALL,
},
}
const systemFeaturesOfficialOnly = {
plugin_installation_permission: {
restrict_to_marketplace_only: false,
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
},
}
it('should allow marketplace plugin when all sources allowed', () => {
const plugin = {
from: 'marketplace' as const,
verification: { authorized_category: 'langgenius' },
}
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
expect(result.canInstall).toBe(true)
})
it('should allow github plugin when all sources allowed', () => {
const plugin = {
from: 'github' as const,
verification: { authorized_category: 'langgenius' },
}
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
expect(result.canInstall).toBe(true)
})
it('should block github plugin when marketplace only', () => {
const plugin = {
from: 'github' as const,
verification: { authorized_category: 'langgenius' },
}
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
expect(result.canInstall).toBe(false)
})
it('should allow marketplace plugin when marketplace only', () => {
const plugin = {
from: 'marketplace' as const,
verification: { authorized_category: 'partner' },
}
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
expect(result.canInstall).toBe(true)
})
it('should allow official plugin when official only', () => {
const plugin = {
from: 'marketplace' as const,
verification: { authorized_category: 'langgenius' },
}
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
expect(result.canInstall).toBe(true)
})
it('should block community plugin when official only', () => {
const plugin = {
from: 'marketplace' as const,
verification: { authorized_category: 'community' },
}
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
expect(result.canInstall).toBe(false)
})
})
describe('plugin source classification', () => {
it('should correctly classify plugin install sources', () => {
const sources = ['marketplace', 'github', 'package'] as const
const features = {
plugin_installation_permission: {
restrict_to_marketplace_only: true,
plugin_installation_scope: InstallationScope.ALL,
},
}
const results = sources.map((source) => ({
source,
canInstall: pluginInstallLimit(
{ from: source, verification: { authorized_category: 'langgenius' } } as never,
features as never,
).canInstall,
}))
expect(results.find((r) => r.source === 'marketplace')?.canInstall).toBe(true)
expect(results.find((r) => r.source === 'github')?.canInstall).toBe(false)
expect(results.find((r) => r.source === 'package')?.canInstall).toBe(false)
})
})
})

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