Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6372d2f7 | ||
|
|
5fd06fafe0 | ||
|
|
af4b65b295 | ||
|
|
6e5fc1081b | ||
|
|
0e84ae7338 | ||
|
|
5a792945f5 | ||
|
|
2f3c785f27 | ||
|
|
38045078dc | ||
|
|
83f40b85f4 | ||
|
|
aff8a49bbf | ||
|
|
b0329cd53c | ||
|
|
2a008423d8 | ||
|
|
f962c9e47a | ||
|
|
af7e59de7c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
-8
@@ -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
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
Generated
+1
-1
@@ -581,7 +581,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0rc1"
|
||||
version = "1.16.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1077
-962
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user