Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-09 19:24:10 +08:00
commit 0438285277
63 changed files with 6192 additions and 1095 deletions

View File

@ -2,7 +2,6 @@ import logging
import time
from typing import cast
from graphon.entities import GraphInitParams
from graphon.enums import WorkflowType
from graphon.graph import Graph
from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent
@ -22,7 +21,7 @@ from core.app.entities.app_invoke_entities import (
)
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id
from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
@ -265,22 +264,23 @@ class PipelineRunner(WorkflowBasedAppRunner):
# graph_config["nodes"] = real_run_nodes
# graph_config["edges"] = real_edges
# init graph
# Create required parameters for Graph.init
graph_init_params = GraphInitParams(
# Create explicit graph init context for Graph.init.
run_context = build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=self._app_id,
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow.id,
graph_config=graph_config,
run_context=build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=self._app_id,
user_id=self.application_generate_entity.user_id,
user_from=user_from,
invoke_from=invoke_from,
),
run_context=run_context,
call_depth=0,
)
node_factory = DifyNodeFactory(
graph_init_params=graph_init_params,
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
)
if start_node_id is None:

View File

@ -3,7 +3,6 @@ import time
from collections.abc import Mapping, Sequence
from typing import Any, cast
from graphon.entities import GraphInitParams
from graphon.entities.graph_config import NodeConfigDictAdapter
from graphon.entities.pause_reason import HumanInputRequired
from graphon.graph import Graph
@ -67,7 +66,12 @@ from core.app.entities.queue_entities import (
QueueWorkflowSucceededEvent,
)
from core.rag.entities import RetrievalSourceMetadata
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id, resolve_workflow_node_class
from core.workflow.node_factory import (
DifyGraphInitContext,
DifyNodeFactory,
get_default_root_node_id,
resolve_workflow_node_class,
)
from core.workflow.system_variables import (
build_bootstrap_variables,
default_system_variables,
@ -127,24 +131,25 @@ class WorkflowBasedAppRunner:
if not isinstance(graph_config.get("edges"), list):
raise ValueError("edges in workflow graph must be a list")
# Create required parameters for Graph.init
graph_init_params = GraphInitParams(
# Create explicit graph init context for Graph.init.
run_context = build_dify_run_context(
tenant_id=tenant_id or "",
app_id=self._app_id,
user_id=user_id,
user_from=user_from,
invoke_from=invoke_from,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow_id,
graph_config=graph_config,
run_context=build_dify_run_context(
tenant_id=tenant_id or "",
app_id=self._app_id,
user_id=user_id,
user_from=user_from,
invoke_from=invoke_from,
),
run_context=run_context,
call_depth=0,
)
# Use the provided graph_runtime_state for consistent state management
node_factory = DifyNodeFactory(
graph_init_params=graph_init_params,
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
)
@ -289,22 +294,23 @@ class WorkflowBasedAppRunner:
typed_node_configs = [NodeConfigDictAdapter.validate_python(node) for node in node_configs]
# Create required parameters for Graph.init
graph_init_params = GraphInitParams(
# Create explicit graph init context for Graph.init.
run_context = build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=self._app_id,
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow.id,
graph_config=graph_config,
run_context=build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=self._app_id,
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
run_context=run_context,
call_depth=0,
)
node_factory = DifyNodeFactory(
graph_init_params=graph_init_params,
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
)

View File

@ -19,5 +19,18 @@ def remove_leading_symbols(text: str) -> str:
# Match Unicode ranges for punctuation and symbols
# FIXME this pattern is confused quick fix for #11868 maybe refactor it later
pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+'
pattern = re.compile(
r"""
^
(?:
[\u2000-\u2025] # General Punctuation: spaces, quotes, dashes
| [\u2027-\u206F] # General Punctuation: ellipsis, underscores, etc.
| [\u2E00-\u2E7F] # Supplemental Punctuation: medieval, ancient marks
| [\u3000-\u300F] # CJK Punctuation: 、。〃「」『》』 (excludes 【】)
| [\u3012-\u303F] # CJK Punctuation: 〖〗〔〕〘〙〚〛〜 etc.
| ["#$%&'()*+,./:;<=>?@^_`~] # ASCII punctuation (excludes []【】)
)+
""",
re.VERBOSE,
)
return re.sub(pattern, "", text)

View File

@ -1,6 +1,7 @@
import importlib
import pkgutil
from collections.abc import Callable, Iterator, Mapping, MutableMapping
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, Any, cast, final, override
@ -67,6 +68,31 @@ _START_NODE_TYPES: frozenset[NodeType] = frozenset(
)
@dataclass(frozen=True, slots=True)
class DifyGraphInitContext:
"""Explicit graph-init values owned by the workflow layer.
Dify is gradually removing direct `GraphInitParams` construction from its
production call sites. Keep the translation here until `graphon` exposes an
equivalent explicit API.
"""
workflow_id: str
graph_config: Mapping[str, Any]
run_context: Mapping[str, Any]
call_depth: int
def to_graph_init_params(self) -> "GraphInitParams":
from graphon.entities import GraphInitParams
return GraphInitParams(
workflow_id=self.workflow_id,
graph_config=self.graph_config,
run_context=self.run_context,
call_depth=self.call_depth,
)
def _import_node_package(package_name: str, *, excluded_modules: frozenset[str] = frozenset()) -> None:
package = importlib.import_module(package_name)
for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
@ -237,6 +263,19 @@ class DifyNodeFactory(NodeFactory):
Default implementation of NodeFactory that resolves node classes from the live registry.
"""
@classmethod
def from_graph_init_context(
cls,
*,
graph_init_context: DifyGraphInitContext,
graph_runtime_state: "GraphRuntimeState",
) -> "DifyNodeFactory":
"""Bridge Dify's explicit init context into the current `graphon` API."""
return cls(
graph_init_params=graph_init_context.to_graph_init_params(),
graph_runtime_state=graph_runtime_state,
)
def __init__(
self,
graph_init_params: "GraphInitParams",

View File

@ -29,7 +29,7 @@ class TriggerWebhookNode(Node[WebhookData]):
def post_init(self) -> None:
from core.workflow.node_runtime import DifyFileReferenceFactory
self._file_reference_factory = DifyFileReferenceFactory(self.graph_init_params.run_context)
self._file_reference_factory = DifyFileReferenceFactory(self.run_context)
@classmethod
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:

View File

@ -24,7 +24,12 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_di
from core.app.file_access import DatabaseFileAccessController
from core.app.workflow.layers.llm_quota import LLMQuotaLayer
from core.app.workflow.layers.observability import ObservabilityLayer
from core.workflow.node_factory import DifyNodeFactory, is_start_node_type, resolve_workflow_node_class
from core.workflow.node_factory import (
DifyGraphInitContext,
DifyNodeFactory,
is_start_node_type,
resolve_workflow_node_class,
)
from core.workflow.system_variables import (
default_system_variables,
get_node_creation_preload_selectors,
@ -251,17 +256,18 @@ class WorkflowEntry:
node_version = str(node_config_data.version)
node_cls = resolve_workflow_node_class(node_type=node_type, node_version=node_version)
# init graph init params and runtime state
graph_init_params = GraphInitParams(
# init graph context and runtime state
run_context = build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow.id,
graph_config=workflow.graph_dict,
run_context=build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
run_context=run_context,
call_depth=0,
)
graph_runtime_state = GraphRuntimeState(
@ -313,8 +319,8 @@ class WorkflowEntry:
)
# init workflow run state
node_factory = DifyNodeFactory(
graph_init_params=graph_init_params,
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
)
node = node_factory.create_node(node_config)
@ -409,17 +415,18 @@ class WorkflowEntry:
variable_pool = VariablePool()
add_variables_to_pool(variable_pool, default_system_variables())
# init graph init params and runtime state
graph_init_params = GraphInitParams(
# init graph context and runtime state
run_context = build_dify_run_context(
tenant_id=tenant_id,
app_id="",
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
graph_init_context = DifyGraphInitContext(
workflow_id="",
graph_config=graph_dict,
run_context=build_dify_run_context(
tenant_id=tenant_id,
app_id="",
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
run_context=run_context,
call_depth=0,
)
graph_runtime_state = GraphRuntimeState(
@ -430,8 +437,8 @@ class WorkflowEntry:
# init workflow run state
node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": node_data})
node_factory = DifyNodeFactory(
graph_init_params=graph_init_params,
node_factory = DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=graph_runtime_state,
)
node = node_factory.create_node(node_config)

View File

@ -171,7 +171,7 @@ dev = [
"sseclient-py>=1.8.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pyrefly>=0.59.1",
"pyrefly>=0.60.0",
]
############################################################

View File

@ -1,14 +1,13 @@
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from extensions.ext_database import db
from core.db.session_factory import session_factory
from models.account import TenantPluginAutoUpgradeStrategy
class PluginAutoUpgradeService:
@staticmethod
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
with sessionmaker(bind=db.engine).begin() as session:
with session_factory.create_session() as session:
return session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
@ -24,7 +23,7 @@ class PluginAutoUpgradeService:
exclude_plugins: list[str],
include_plugins: list[str],
) -> bool:
with sessionmaker(bind=db.engine).begin() as session:
with session_factory.create_session() as session:
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
@ -51,7 +50,7 @@ class PluginAutoUpgradeService:
@staticmethod
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
with sessionmaker(bind=db.engine).begin() as session:
with session_factory.create_session() as session:
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)

View File

@ -5,7 +5,7 @@ import uuid
from collections.abc import Callable, Generator, Mapping, Sequence
from typing import Any, cast
from graphon.entities import GraphInitParams, WorkflowNodeExecution
from graphon.entities import WorkflowNodeExecution
from graphon.entities.graph_config import NodeConfigDict
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import (
@ -48,7 +48,12 @@ from core.workflow.human_input_compat import (
normalize_human_input_node_data_for_graph,
parse_human_input_delivery_methods,
)
from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping, is_start_node_type
from core.workflow.node_factory import (
LATEST_VERSION,
DifyGraphInitContext,
get_node_type_classes_mapping,
is_start_node_type,
)
from core.workflow.node_runtime import DifyHumanInputNodeRuntime, apply_dify_debug_email_recipient
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables, default_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
@ -1132,18 +1137,20 @@ class WorkflowService:
node_config: NodeConfigDict,
variable_pool: VariablePool,
) -> HumanInputNode:
graph_init_params = GraphInitParams(
run_context = build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
user_id=account.id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
graph_init_context = DifyGraphInitContext(
workflow_id=workflow.id,
graph_config=workflow.graph_dict,
run_context=build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
user_id=account.id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
run_context=run_context,
call_depth=0,
)
graph_init_params = graph_init_context.to_graph_init_params()
graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool,
start_at=time.perf_counter(),
@ -1153,7 +1160,7 @@ class WorkflowService:
config=node_config,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context),
runtime=DifyHumanInputNodeRuntime(run_context),
)
return node

View File

@ -110,6 +110,34 @@ class TestFetchMemory:
)
class TestDifyGraphInitContext:
def test_to_graph_init_params_preserves_explicit_values(self):
run_context = {
DIFY_RUN_CONTEXT_KEY: DifyRunContext(
tenant_id="tenant-id",
app_id="app-id",
user_id="user-id",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
"extra": "value",
}
graph_config = {"nodes": [], "edges": []}
graph_init_context = node_factory.DifyGraphInitContext(
workflow_id="workflow-id",
graph_config=graph_config,
run_context=run_context,
call_depth=2,
)
result = graph_init_context.to_graph_init_params()
assert result.workflow_id == "workflow-id"
assert result.graph_config == graph_config
assert result.run_context == run_context
assert result.call_depth == 2
class TestDefaultWorkflowCodeExecutor:
def test_execute_delegates_to_code_executor(self, monkeypatch):
executor = node_factory.DefaultWorkflowCodeExecutor()
@ -172,6 +200,23 @@ class TestCodeExecutorJinja2TemplateRenderer:
class TestDifyNodeFactoryInit:
def test_from_graph_init_context_translates_before_init(self):
graph_init_context = MagicMock()
graph_init_context.to_graph_init_params.return_value = sentinel.graph_init_params
with patch.object(node_factory.DifyNodeFactory, "__init__", return_value=None) as init:
factory = node_factory.DifyNodeFactory.from_graph_init_context(
graph_init_context=graph_init_context,
graph_runtime_state=sentinel.graph_runtime_state,
)
assert isinstance(factory, node_factory.DifyNodeFactory)
graph_init_context.to_graph_init_params.assert_called_once_with()
init.assert_called_once_with(
graph_init_params=sentinel.graph_init_params,
graph_runtime_state=sentinel.graph_runtime_state,
)
def test_init_builds_default_dependencies(self):
graph_init_params = SimpleNamespace(run_context={"context": "value"})
graph_runtime_state = sentinel.graph_runtime_state

View File

@ -349,7 +349,7 @@ class TestWorkflowEntrySingleStepRun:
]
with (
patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params),
patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context),
patch.object(
workflow_entry,
"GraphRuntimeState",
@ -358,7 +358,7 @@ class TestWorkflowEntrySingleStepRun:
patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}),
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeLLMNode),
patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory,
patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory,
patch.object(workflow_entry, "load_into_variable_pool"),
patch.object(workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool"),
patch.object(
@ -412,12 +412,12 @@ class TestWorkflowEntrySingleStepRun:
raise NotImplementedError
with (
patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params),
patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context),
patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state),
patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}),
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode),
patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory,
patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory,
patch.object(workflow_entry, "add_node_inputs_to_pool") as add_node_inputs_to_pool,
patch.object(workflow_entry, "load_into_variable_pool") as load_into_variable_pool,
patch.object(
@ -481,12 +481,12 @@ class TestWorkflowEntrySingleStepRun:
return {"question": ["node", "question"]}
with (
patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params),
patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context),
patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state),
patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}),
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeDatasourceNode),
patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory,
patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory,
patch.object(workflow_entry, "add_node_inputs_to_pool") as add_node_inputs_to_pool,
patch.object(workflow_entry, "load_into_variable_pool") as load_into_variable_pool,
patch.object(
@ -541,12 +541,12 @@ class TestWorkflowEntrySingleStepRun:
return "1"
with (
patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params),
patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context),
patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state),
patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}),
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
patch.object(workflow_entry, "resolve_workflow_node_class", return_value=FakeNode),
patch.object(workflow_entry, "DifyNodeFactory") as dify_node_factory,
patch.object(workflow_entry.DifyNodeFactory, "from_graph_init_context") as dify_node_factory,
patch.object(workflow_entry, "add_node_inputs_to_pool"),
patch.object(workflow_entry, "load_into_variable_pool"),
patch.object(workflow_entry.WorkflowEntry, "mapping_user_inputs_to_variable_pool"),
@ -651,14 +651,18 @@ class TestWorkflowEntryHelpers:
patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool) as variable_pool_cls,
patch.object(workflow_entry, "add_variables_to_pool") as add_variables_to_pool,
patch.object(
workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params
) as graph_init_params,
workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context
) as graph_init_context_cls,
patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state),
patch.object(
workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}
) as build_dify_run_context,
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
patch.object(workflow_entry, "DifyNodeFactory", return_value=dify_node_factory) as dify_node_factory_cls,
patch.object(
workflow_entry.DifyNodeFactory,
"from_graph_init_context",
return_value=dify_node_factory,
) as dify_node_factory_cls,
patch.object(
workflow_entry.WorkflowEntry,
"mapping_user_inputs_to_variable_pool",
@ -688,7 +692,7 @@ class TestWorkflowEntryHelpers:
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
graph_init_params.assert_called_once_with(
graph_init_context_cls.assert_called_once_with(
workflow_id="",
graph_config=workflow_entry.WorkflowEntry._create_single_node_graph(
"node-id", {"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR, "title": "Node"}
@ -697,7 +701,7 @@ class TestWorkflowEntryHelpers:
call_depth=0,
)
dify_node_factory_cls.assert_called_once_with(
graph_init_params=sentinel.graph_init_params,
graph_init_context=sentinel.graph_init_context,
graph_runtime_state=sentinel.graph_runtime_state,
)
mapping_user_inputs_to_variable_pool.assert_called_once_with(
@ -734,11 +738,15 @@ class TestWorkflowEntryHelpers:
patch.object(workflow_entry, "default_system_variables", return_value=sentinel.system_variables),
patch.object(workflow_entry, "VariablePool", return_value=sentinel.variable_pool),
patch.object(workflow_entry, "add_variables_to_pool"),
patch.object(workflow_entry, "GraphInitParams", return_value=sentinel.graph_init_params),
patch.object(workflow_entry, "DifyGraphInitContext", return_value=sentinel.graph_init_context),
patch.object(workflow_entry, "GraphRuntimeState", return_value=sentinel.graph_runtime_state),
patch.object(workflow_entry, "build_dify_run_context", return_value={"_dify": "context"}),
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
patch.object(workflow_entry, "DifyNodeFactory", return_value=dify_node_factory),
patch.object(
workflow_entry.DifyNodeFactory,
"from_graph_init_context",
return_value=dify_node_factory,
),
patch.object(
workflow_entry.WorkflowEntry,
"mapping_user_inputs_to_variable_pool",

View File

@ -6,23 +6,23 @@ MODULE = "services.plugin.plugin_auto_upgrade_service"
def _patched_session():
"""Patch sessionmaker(bind=db.engine).begin() to return a mock session as context manager."""
"""Patch session_factory.create_session() to return a mock session as context manager."""
session = MagicMock()
mock_sessionmaker = MagicMock()
mock_sessionmaker.return_value.begin.return_value.__enter__ = MagicMock(return_value=session)
mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False)
patcher = patch(f"{MODULE}.sessionmaker", mock_sessionmaker)
db_patcher = patch(f"{MODULE}.db")
return patcher, db_patcher, session
session.__enter__ = MagicMock(return_value=session)
session.__exit__ = MagicMock(return_value=False)
mock_factory = MagicMock()
mock_factory.create_session.return_value = session
patcher = patch(f"{MODULE}.session_factory", mock_factory)
return patcher, session
class TestGetStrategy:
def test_returns_strategy_when_found(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
strategy = MagicMock()
session.scalar.return_value = strategy
with p1, p2:
with p1:
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.get_strategy("t1")
@ -30,10 +30,10 @@ class TestGetStrategy:
assert result is strategy
def test_returns_none_when_not_found(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
session.scalar.return_value = None
with p1, p2:
with p1:
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.get_strategy("t1")
@ -43,10 +43,10 @@ class TestGetStrategy:
class TestChangeStrategy:
def test_creates_new_strategy(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
session.scalar.return_value = None
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.return_value = MagicMock()
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
@ -63,11 +63,11 @@ class TestChangeStrategy:
session.add.assert_called_once()
def test_updates_existing_strategy(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
existing = MagicMock()
session.scalar.return_value = existing
with p1, p2:
with p1:
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.change_strategy(
@ -89,12 +89,11 @@ class TestChangeStrategy:
class TestExcludePlugin:
def test_creates_default_strategy_when_none_exists(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
session.scalar.return_value = None
with (
p1,
p2,
patch(f"{MODULE}.select"),
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls,
patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs,
@ -110,13 +109,13 @@ class TestExcludePlugin:
cs.assert_called_once()
def test_appends_to_exclude_list_in_exclude_mode(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "exclude"
existing.exclude_plugins = ["p-existing"]
session.scalar.return_value = existing
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
@ -128,13 +127,13 @@ class TestExcludePlugin:
assert existing.exclude_plugins == ["p-existing", "p-new"]
def test_removes_from_include_list_in_partial_mode(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "partial"
existing.include_plugins = ["p1", "p2"]
session.scalar.return_value = existing
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
@ -146,12 +145,12 @@ class TestExcludePlugin:
assert existing.include_plugins == ["p2"]
def test_switches_to_exclude_mode_from_all(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "all"
session.scalar.return_value = existing
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"
@ -164,13 +163,13 @@ class TestExcludePlugin:
assert existing.exclude_plugins == ["p1"]
def test_no_duplicate_in_exclude_list(self):
p1, p2, session = _patched_session()
p1, session = _patched_session()
existing = MagicMock()
existing.upgrade_mode = "exclude"
existing.exclude_plugins = ["p1"]
session.scalar.return_value = existing
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
strat_cls.UpgradeMode.EXCLUDE = "exclude"
strat_cls.UpgradeMode.PARTIAL = "partial"
strat_cls.UpgradeMode.ALL = "all"

View File

@ -2753,9 +2753,9 @@ class TestWorkflowServiceFreeNodeExecution:
variable_pool = MagicMock()
with (
patch("services.workflow_service.GraphInitParams") as mock_graph_init_params,
patch("services.workflow_service.DifyGraphInitContext") as mock_graph_init_context_cls,
patch("services.workflow_service.GraphRuntimeState"),
patch("services.workflow_service.build_dify_run_context"),
patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context,
patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls,
patch("services.workflow_service.HumanInputNode") as mock_node_cls,
):
@ -2764,4 +2764,17 @@ class TestWorkflowServiceFreeNodeExecution:
)
assert node == mock_node_cls.return_value
mock_node_cls.assert_called_once()
mock_runtime_cls.assert_called_once_with(mock_graph_init_params.return_value.run_context)
mock_graph_init_context_cls.assert_called_once_with(
workflow_id="wf-1",
graph_config=workflow.graph_dict,
run_context=mock_build_dify_run_context.return_value,
call_depth=0,
)
mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_node_cls.assert_called_once_with(
id="n-1",
config=node_config,
graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value,
graph_runtime_state=ANY,
runtime=mock_runtime_cls.return_value,
)

View File

@ -19,7 +19,57 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols
("[Google](https://google.com) is a search engine", "[Google](https://google.com) is a search engine"),
("[Example](http://example.com) some text", "[Example](http://example.com) some text"),
# Leading symbols before markdown link are removed, including the opening bracket [
("@[Test](https://example.com)", "Test](https://example.com)"),
("@[Test](https://example.com)", "[Test](https://example.com)"),
("~~标题~~", "标题~~"),
('""quoted', "quoted"),
("''test", "test"),
("##话题", "话题"),
("$$价格", "价格"),
("%%百分比", "百分比"),
("&&与逻辑", "与逻辑"),
("((括号))", "括号))"),
("**强调**", "强调**"),
("++自增", "自增"),
(",,逗号", "逗号"),
("..省略", "省略"),
("//注释", "注释"),
("::范围", "范围"),
(";;分号", "分号"),
("<<左移", "左移"),
("==等于", "等于"),
(">>右移", "右移"),
("??疑问", "疑问"),
("@@提及", "提及"),
("^^上标", "上标"),
("__下划线", "下划线"),
("``代码", "代码"),
("~~删除线", "删除线"),
(" 全角空格开头", "全角空格开头"),
("、顿号开头", "顿号开头"),
("。句号开头", "句号开头"),
("「引号」测试", "引号」测试"),
("『书名号』", "书名号』"),
("【保留】测试", "【保留】测试"),
("〖括号〗测试", "括号〗测试"),
("〔括号〕测试", "括号〕测试"),
("~~【保留】~~", "【保留】~~"),
('"[公告]"', '[公告]"'),
("[公告] 更新", "[公告] 更新"),
("【通知】重要", "【通知】重要"),
("[[嵌套]]", "[[嵌套]]"),
("【【嵌套】】", "【【嵌套】】"),
("[【混合】]", "[【混合】]"),
("normal text", "normal text"),
("123数字", "123数字"),
("中文开头", "中文开头"),
("alpha", "alpha"),
("~", ""),
("", ""),
("[", "["),
("~~~", ""),
("【【【", "【【【"),
("\t制表符", "\t制表符"),
("\n换行", "\n换行"),
],
)
def test_remove_leading_symbols(input_text, expected_output):

24
api/uv.lock generated
View File

@ -1572,7 +1572,7 @@ dev = [
{ name = "lxml-stubs", specifier = "~=0.5.1" },
{ name = "mypy", specifier = "~=1.20.0" },
{ name = "pandas-stubs", specifier = "~=3.0.0" },
{ name = "pyrefly", specifier = ">=0.59.1" },
{ name = "pyrefly", specifier = ">=0.60.0" },
{ name = "pytest", specifier = "~=9.0.2" },
{ name = "pytest-benchmark", specifier = "~=5.2.3" },
{ name = "pytest-cov", specifier = "~=7.1.0" },
@ -4825,19 +4825,19 @@ wheels = [
[[package]]
name = "pyrefly"
version = "0.59.1"
version = "0.60.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/ce/7882c2af92b2ff6505fcd3430eff8048ece6c6254cc90bdc76ecee12dfab/pyrefly-0.59.1.tar.gz", hash = "sha256:bf1675b0c38d45df2c8f8618cbdfa261a1b92430d9d31eba16e0282b551e210f", size = 5475432, upload-time = "2026-04-01T22:04:04.11Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/c7/28d14b64888e2d03815627ebff8d57a9f08389c4bbebfe70ae1ed98a1267/pyrefly-0.60.0.tar.gz", hash = "sha256:2499f5b6ff5342e86dfe1cd94bcce133519bbbc93b7ad5636195fea4f0fa3b81", size = 5500389, upload-time = "2026-04-06T19:57:30.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/10/04a0e05b08fc855b6fe38c3df549925fc3c2c6e750506870de7335d3e1f7/pyrefly-0.59.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:390db3cd14aa7e0268e847b60cd9ee18b04273eddfa38cf341ed3bb43f3fef2a", size = 12868133, upload-time = "2026-04-01T22:03:39.436Z" },
{ url = "https://files.pythonhosted.org/packages/c7/78/fa7be227c3e3fcacee501c1562278dd026186ffd1b5b5beb51d3941a3aed/pyrefly-0.59.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d246d417b6187c1650d7f855f61c68fbfd6d6155dc846d4e4d273a3e6b5175cb", size = 12379325, upload-time = "2026-04-01T22:03:42.046Z" },
{ url = "https://files.pythonhosted.org/packages/bb/13/6828ce1c98171b5f8388f33c4b0b9ea2ab8c49abe0ef8d793c31e30a05cb/pyrefly-0.59.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:575ac67b04412dc651a7143d27e38a40fbdd3c831c714d5520d0e9d4c8631ab4", size = 35826408, upload-time = "2026-04-01T22:03:45.067Z" },
{ url = "https://files.pythonhosted.org/packages/23/56/79ed8ece9a7ecad0113c394a06a084107db3ad8f1fefe19e7ded43c51245/pyrefly-0.59.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:062e6262ce1064d59dcad81ac0499bb7a3ad501e9bc8a677a50dc630ff0bf862", size = 38532699, upload-time = "2026-04-01T22:03:48.376Z" },
{ url = "https://files.pythonhosted.org/packages/18/7d/ecc025e0f0e3f295b497f523cc19cefaa39e57abede8fc353d29445d174b/pyrefly-0.59.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ef4247f9e6f734feb93e1f2b75335b943629956e509f545cc9cdcccd76dd20", size = 36743570, upload-time = "2026-04-01T22:03:51.362Z" },
{ url = "https://files.pythonhosted.org/packages/2f/03/b1ce882ebcb87c673165c00451fbe4df17bf96ccfde18c75880dc87c5f5e/pyrefly-0.59.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a2d01723b84d042f4fa6ec871ffd52d0a7e83b0ea791c2e0bb0ff750abce56", size = 41236246, upload-time = "2026-04-01T22:03:54.361Z" },
{ url = "https://files.pythonhosted.org/packages/17/af/5e9c7afd510e7dd64a2204be0ed39e804089cbc4338675a28615c7176acb/pyrefly-0.59.1-py3-none-win32.whl", hash = "sha256:4ea70c780848f8376411e787643ae5d2d09da8a829362332b7b26d15ebcbaf56", size = 11884747, upload-time = "2026-04-01T22:03:56.776Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c1/7db1077627453fd1068f0761f059a9512645c00c4c20acfb9f0c24ac02ec/pyrefly-0.59.1-py3-none-win_amd64.whl", hash = "sha256:67e6a08cfd129a0d2788d5e40a627f9860e0fe91a876238d93d5c63ff4af68ae", size = 12720608, upload-time = "2026-04-01T22:03:59.252Z" },
{ url = "https://files.pythonhosted.org/packages/07/16/4bb6e5fce5a9cf0992932d9435d964c33e507aaaf96fdfbb1be493078a4a/pyrefly-0.59.1-py3-none-win_arm64.whl", hash = "sha256:01179cb215cf079e8223a064f61a074f7079aa97ea705cbbc68af3d6713afd15", size = 12223158, upload-time = "2026-04-01T22:04:01.869Z" },
{ url = "https://files.pythonhosted.org/packages/31/99/6c9984a09220e5eb7dd5c869b7a32d25c3d06b5e8854c6eb679db1145c3e/pyrefly-0.60.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bf1691af0fee69d0c99c3c6e9d26ab6acd3c8afef96416f9ba2e74934833b7b5", size = 12921262, upload-time = "2026-04-06T19:57:00.745Z" },
{ url = "https://files.pythonhosted.org/packages/05/b3/6216aa3c00c88e59a27eb4149851b5affe86eeea6129f4224034a32dddb0/pyrefly-0.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3e71b70c9b95545cf3b479bc55d1381b531de7b2380eb64411088a1e56b634cb", size = 12424413, upload-time = "2026-04-06T19:57:03.417Z" },
{ url = "https://files.pythonhosted.org/packages/9b/87/eb8dd73abd92a93952ac27a605e463c432fb250fb23186574038c7035594/pyrefly-0.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:680ee5f8f98230ea145652d7344708f5375786209c5bf03d8b911fdb0d0d4195", size = 35940884, upload-time = "2026-04-06T19:57:06.909Z" },
{ url = "https://files.pythonhosted.org/packages/0d/34/dc6aeb67b840c745fcee6db358295d554abe6ab555a7eaaf44624bd80bf1/pyrefly-0.60.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d0b20dbbe4aff15b959e8d825b7521a144c4122c11e57022e83b36568c54470", size = 38677220, upload-time = "2026-04-06T19:57:11.235Z" },
{ url = "https://files.pythonhosted.org/packages/66/6b/c863fcf7ef592b7d1db91502acf0d1113be8bed7a2a7143fc6f0dd90616f/pyrefly-0.60.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2911563c8e6b2eaefff68885c94727965469a35375a409235a7a4d2b7157dc15", size = 36907431, upload-time = "2026-04-06T19:57:15.074Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/25ea095ab2ecca8e62884669b11a79f14299db93071685b73a97efbaf4f3/pyrefly-0.60.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0a631d9d04705e303fe156f2e62551611bc7ef8066c34708ceebcfb3088bd55", size = 41447898, upload-time = "2026-04-06T19:57:19.382Z" },
{ url = "https://files.pythonhosted.org/packages/8e/2c/097bdc6e8d40676b28eb03710a4577bc3c7b803cd24693ac02bf15de3d67/pyrefly-0.60.0-py3-none-win32.whl", hash = "sha256:a08d69298da5626cf502d3debbb6944fd13d2f405ea6625363751f1ff570d366", size = 11913434, upload-time = "2026-04-06T19:57:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/0a/d4/8d27fe310e830c8d11ab73db38b93f9fd2e218744b6efb1204401c9a74d5/pyrefly-0.60.0-py3-none-win_amd64.whl", hash = "sha256:56cf30654e708ae1dd635ffefcba4fa4b349dd7004a6ccc5c41e3a9bb944320c", size = 12745033, upload-time = "2026-04-06T19:57:25.517Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ad/8eea1f8fb8209f91f6dbfe48000c9d05fd0cdb1b5b3157283c9b1dada55d/pyrefly-0.60.0-py3-none-win_arm64.whl", hash = "sha256:b6d27fba970f4777063c0227c54167d83bece1804ea34f69e7118e409ba038d2", size = 12246390, upload-time = "2026-04-06T19:57:28.141Z" },
]
[[package]]

1248
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -47,7 +47,7 @@ overrides:
catalog:
"@amplitude/analytics-browser": 2.38.1
"@amplitude/plugin-session-replay-browser": 1.27.6
"@antfu/eslint-config": 8.0.0
"@antfu/eslint-config": 8.1.1
"@base-ui/react": 1.3.0
"@chromatic-com/storybook": 5.1.1
"@cucumber/cucumber": 12.7.0
@ -73,8 +73,8 @@ catalog:
"@mdx-js/react": 3.1.1
"@mdx-js/rollup": 3.1.1
"@monaco-editor/react": 4.7.0
"@next/eslint-plugin-next": 16.2.2
"@next/mdx": 16.2.2
"@next/eslint-plugin-next": 16.2.3
"@next/mdx": 16.2.3
"@orpc/client": 1.13.13
"@orpc/contract": 1.13.13
"@orpc/openapi-client": 1.13.13
@ -120,9 +120,9 @@ catalog:
"@types/sortablejs": 1.15.9
"@typescript-eslint/eslint-plugin": 8.58.1
"@typescript-eslint/parser": 8.58.1
"@typescript/native-preview": 7.0.0-dev.20260407.1
"@typescript/native-preview": 7.0.0-dev.20260408.1
"@vitejs/plugin-react": 6.0.1
"@vitejs/plugin-rsc": 0.5.22
"@vitejs/plugin-rsc": 0.5.23
"@vitest/coverage-v8": 4.1.3
abcjs: 6.6.2
agentation: 3.0.2
@ -146,7 +146,7 @@ catalog:
emoji-mart: 5.6.0
es-toolkit: 1.45.1
eslint: 10.2.0
eslint-markdown: 0.6.0
eslint-markdown: 0.6.1
eslint-plugin-better-tailwindcss: 4.3.2
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.0
@ -160,7 +160,7 @@ catalog:
hono: 4.12.12
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.3
i18next: 26.0.4
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.1.2
immer: 11.1.4
@ -170,7 +170,7 @@ catalog:
js-yaml: 4.1.1
jsonschema: 1.5.0
katex: 0.16.45
knip: 6.3.0
knip: 6.3.1
ky: 2.0.0
lamejs: 1.2.1
lexical: 0.42.0
@ -178,24 +178,24 @@ catalog:
mime: 4.1.0
mitt: 3.0.1
negotiator: 1.0.0
next: 16.2.2
next: 16.2.3
next-themes: 0.4.6
nuqs: 2.8.9
pinyin-pro: 3.28.0
postcss: 8.5.9
postcss-js: 5.1.0
qrcode.react: 4.2.0
qs: 6.15.0
react: 19.2.4
qs: 6.15.1
react: 19.2.5
react-18-input-autosize: 3.0.0
react-dom: 19.2.4
react-dom: 19.2.5
react-easy-crop: 5.5.7
react-hotkeys-hook: 5.2.4
react-i18next: 17.0.2
react-multi-email: 1.0.25
react-papaparse: 4.4.0
react-pdf-highlighter: 8.0.0-rc.0
react-server-dom-webpack: 19.2.4
react-server-dom-webpack: 19.2.5
react-sortablejs: 6.1.4
react-textarea-autosize: 8.5.9
reactflow: 11.11.4
@ -219,7 +219,7 @@ catalog:
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.40
vinext: 0.0.41
vite: npm:@voidzero-dev/vite-plus-core@0.1.16
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.16

View File

@ -0,0 +1,224 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DatasetInfo from '@/app/components/app-sidebar/dataset-info'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
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('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({
dataset: mockDataset,
}),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) =>
selector({ isCurrentWorkspaceDatasetOperator: false }),
}))
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,
...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(screen.getByTestId('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')
})
})
})

View File

@ -0,0 +1,199 @@
import type { SVGProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppDetailNav from '@/app/components/app-sidebar'
const mockSetAppSidebarExpand = vi.fn()
let mockAppSidebarExpand = 'expand'
let mockPathname = '/app/app-1/logs'
let mockSelectedSegment = 'logs'
let mockIsHovering = true
let keyPressHandler: ((event: { preventDefault: () => void }) => void) | null = null
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: {
id: 'app-1',
name: 'Demo App',
mode: 'chat',
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: null,
},
appSidebarExpand: mockAppSidebarExpand,
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector,
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
useSelectedLayoutSegment: () => mockSelectedSegment,
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
className,
title,
}: {
href: string
children?: React.ReactNode
className?: string
title?: string
}) => (
<a href={href} className={className} title={title}>
{children}
</a>
),
}))
vi.mock('ahooks', () => ({
useHover: () => mockIsHovering,
useKeyPress: (_key: string, handler: (event: { preventDefault: () => void }) => void) => {
keyPressHandler = handler
},
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
getKeyboardKeyNameBySystem: (key: string) => key,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await vi.importActual<typeof import('react')>('react')
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" data-testid="portal-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = React.useContext(OpenContext)
return open ? <div>{children}</div> : null
},
}
})
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/app-sidebar/app-info', () => ({
default: ({
expand,
onlyShowDetail,
openState,
}: {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
}) => (
<div
data-testid={onlyShowDetail ? 'app-info-detail' : 'app-info'}
data-expand={expand}
data-open={openState}
/>
),
}))
const MockIcon = (props: SVGProps<SVGSVGElement>) => <svg {...props} />
const navigation = [
{ name: 'Overview', href: '/app/app-1/overview', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Logs', href: '/app/app-1/logs', icon: MockIcon, selectedIcon: MockIcon },
]
describe('App Sidebar Shell Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockAppSidebarExpand = 'expand'
mockPathname = '/app/app-1/logs'
mockSelectedSegment = 'logs'
mockIsHovering = true
keyPressHandler = null
})
it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true')
const logsLink = screen.getByRole('link', { name: /Logs/i })
expect(logsLink.className).toContain('bg-components-menu-item-bg-active')
fireEvent.click(screen.getByRole('button'))
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
const preventDefault = vi.fn()
keyPressHandler?.({ preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
})
it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', () => {
mockPathname = '/app/app-1/workflow'
mockSelectedSegment = 'workflow'
localStorage.setItem('workflow-canvas-maximize', 'true')
render(<AppDetailNav navigation={navigation} />)
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByText('Demo App')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,139 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppPublisher from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockFetchAppDetailDirect = 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
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
}),
}))
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', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
isLoading: false,
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: () => null,
}))
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',
},
}
mockFetchAppDetailDirect.mockResolvedValue({
...mockAppDetail,
access_mode: AccessMode.PUBLIC,
})
})
it('refreshes app detail after confirming access control updates', async () => {
render(<AppPublisher publishedAt={1700000000} />)
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(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
}))
})
await waitFor(() => {
expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,243 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppPublisher from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockTrackEvent = vi.fn()
const mockRefetch = 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
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
}),
}))
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', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
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('@/app/components/base/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/app/app-access-control', () => ({
default: () => <div data-testid="app-access-control" />,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await vi.importActual<typeof import('react')>('react')
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<div onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = React.useContext(OpenContext)
return open ? <div>{children}</div> : null
},
}
})
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
getKeyboardKeyNameBySystem: (key: string) => key,
}))
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)
render(
<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',
}))
})
expect(mockRefetch).toHaveBeenCalled()
})
it('opens embedded modal and resolves the installed explore target', async () => {
render(<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: [],
})
render(<AppPublisher publishedAt={1700000000} />)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('common.openInExplore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
})
})
})

View File

@ -0,0 +1,154 @@
import type { RefObject } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
vi.mock('@/app/components/base/chat/chat-with-history/hooks', () => ({
useChatWithHistory: vi.fn(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
})),
usePathname: vi.fn(() => '/'),
useSearchParams: vi.fn(() => new URLSearchParams()),
useParams: vi.fn(() => ({})),
}))
type HookReturn = ReturnType<typeof useChatWithHistory>
const mockAppData = {
site: { title: 'Test Chat', chat_color_theme: 'blue', chat_color_theme_inverted: false },
} as unknown as AppData
const defaultHookReturn: HookReturn = {
isInstalledApp: false,
appId: 'test-app-id',
currentConversationId: '',
currentConversationItem: undefined,
handleConversationIdInfoChange: vi.fn(),
appData: mockAppData,
appParams: {} as ChatConfig,
appMeta: {} as AppMeta,
appPinnedConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData,
appConversationData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData,
appConversationDataLoading: false,
appChatListData: { data: [] as ConversationItem[], has_more: false, limit: 20 } as AppConversationData,
appChatListDataLoading: false,
appPrevChatTree: [],
pinnedConversationList: [],
conversationList: [],
setShowNewConversationItemInList: vi.fn(),
newConversationInputs: {},
newConversationInputsRef: { current: {} } as unknown as RefObject<Record<string, unknown>>,
handleNewConversationInputsChange: vi.fn(),
inputsForms: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handlePinConversation: vi.fn(),
handleUnpinConversation: vi.fn(),
conversationDeleting: false,
handleDeleteConversation: vi.fn(),
conversationRenaming: false,
handleRenameConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
newConversationId: '',
chatShouldReloadKey: 'test-reload-key',
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
sidebarCollapseState: false,
handleSidebarCollapse: vi.fn(),
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
setIsResponding: vi.fn(),
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
initUserVariables: {},
}
describe('Base Chat Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
vi.mocked(useChatWithHistory).mockReturnValue(defaultHookReturn)
renderHook(() => useThemeContext()).result.current.buildTheme()
})
// Chat-with-history shell integration across layout, responsive shell, and theme setup.
describe('Chat With History Shell', () => {
it('builds theme, updates the document title, and expands the collapsed desktop sidebar on hover', async () => {
const themeBuilder = renderHook(() => useThemeContext()).result.current
const { container } = render(<ChatWithHistory className="chat-history-shell" />)
const titles = screen.getAllByText('Test Chat')
expect(titles.length).toBeGreaterThan(0)
expect(useDocumentTitle).toHaveBeenCalledWith('Test Chat')
await waitFor(() => {
expect(themeBuilder.theme.primaryColor).toBe('blue')
expect(themeBuilder.theme.chatColorThemeInverted).toBe(false)
})
vi.mocked(useChatWithHistory).mockReturnValue({
...defaultHookReturn,
sidebarCollapseState: true,
})
const { container: collapsedContainer } = render(<ChatWithHistory />)
const hoverArea = collapsedContainer.querySelector('.absolute.top-0.z-20')
expect(container.querySelector('.chat-history-shell')).toBeInTheDocument()
expect(hoverArea).toBeInTheDocument()
if (hoverArea) {
fireEvent.mouseEnter(hoverArea)
expect(hoverArea).toHaveClass('left-0')
fireEvent.mouseLeave(hoverArea)
expect(hoverArea).toHaveClass('left-[-248px]')
}
})
it('falls back to the mobile loading shell when site metadata is unavailable', () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
vi.mocked(useChatWithHistory).mockReturnValue({
...defaultHookReturn,
appData: null,
appChatListDataLoading: true,
})
const { container } = render(<ChatWithHistory className="mobile-chat-shell" />)
expect(useDocumentTitle).toHaveBeenCalledWith('Chat')
expect(screen.getByRole('status')).toBeInTheDocument()
expect(container.querySelector('.mobile-chat-shell')).toBeInTheDocument()
expect(container.querySelector('.rounded-t-2xl')).toBeInTheDocument()
expect(container.querySelector('.rounded-2xl')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,106 @@
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FileUploaderInAttachmentWrapper from '@/app/components/base/file-uploader/file-uploader-in-attachment'
import FileUploaderInChatInput from '@/app/components/base/file-uploader/file-uploader-in-chat-input'
import { FileContextProvider } from '@/app/components/base/file-uploader/store'
import { TransferMethod } from '@/types/app'
const mockUploadRemoteFileInfo = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
}))
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
}))
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
enabled: true,
allowed_file_types: ['document'],
allowed_file_extensions: [],
allowed_file_upload_methods: [TransferMethod.remote_url],
number_limits: 5,
preview_config: {
enabled: false,
mode: 'current_page',
file_type_list: [],
},
...overrides,
} as FileUpload)
const renderChatInput = (fileConfig: FileUpload, readonly = false) => {
return render(
<FileContextProvider>
<FileUploaderInChatInput fileConfig={fileConfig} readonly={readonly} />
</FileContextProvider>,
)
}
describe('Base File Uploader Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUploadRemoteFileInfo.mockResolvedValue({
id: 'remote-file-1',
mime_type: 'application/pdf',
size: 2048,
name: 'guide.pdf',
url: 'https://cdn.example.com/guide.pdf',
})
})
it('uploads a remote file from the attachment wrapper and pushes the updated file list to consumers', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FileUploaderInAttachmentWrapper
value={[]}
onChange={onChange}
fileConfig={createFileConfig()}
/>,
)
await user.click(screen.getByRole('button', { name: /fileUploader\.pasteFileLink/i }))
await user.type(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i), 'https://example.com/guide.pdf')
await user.click(screen.getByRole('button', { name: /operation\.ok/i }))
await waitFor(() => {
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/guide.pdf', false)
})
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({
name: 'https://example.com/guide.pdf',
uploadedId: 'remote-file-1',
url: 'https://cdn.example.com/guide.pdf',
progress: 100,
}),
])
})
expect(screen.getByText('https://example.com/guide.pdf')).toBeInTheDocument()
})
it('opens the link picker from chat input and keeps the trigger disabled in readonly mode', async () => {
const user = userEvent.setup()
const fileConfig = createFileConfig()
const { unmount } = renderChatInput(fileConfig)
const activeTrigger = screen.getByRole('button')
expect(activeTrigger).toBeEnabled()
await user.click(activeTrigger)
expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/i)).toBeInTheDocument()
expect(screen.queryByText(/fileUploader\.uploadFromComputer/i)).not.toBeInTheDocument()
unmount()
renderChatInput(fileConfig, true)
expect(screen.getByRole('button')).toBeDisabled()
})
})

View File

@ -0,0 +1,65 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DemoForm from '@/app/components/base/form/form-scenarios/demo'
describe('Base Form Demo Flow', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
beforeEach(() => {
vi.clearAllMocks()
})
it('reveals contact fields and submits the composed form values through the shared form actions', async () => {
const user = userEvent.setup()
render(<DemoForm />)
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: /^name$/i }), 'Alice')
await user.type(screen.getByRole('textbox', { name: /^surname$/i }), 'Smith')
await user.click(screen.getByText(/i accept the terms and conditions/i))
expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: /^email$/i }), 'alice@example.com')
const preferredMethodLabel = screen.getByText('Preferred Contact Method')
const preferredMethodField = preferredMethodLabel.parentElement?.parentElement
expect(preferredMethodField).toBeTruthy()
await user.click(within(preferredMethodField as HTMLElement).getByText('Email'))
await user.click(screen.getByText('Whatsapp'))
const submitButton = screen.getByRole('button', { name: /operation\.submit/i })
expect(submitButton).toBeEnabled()
await user.click(submitButton)
await waitFor(() => {
expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({
name: 'Alice',
surname: 'Smith',
isAcceptingTerms: true,
contact: expect.objectContaining({
email: 'alice@example.com',
preferredContactMethod: 'whatsapp',
}),
}))
})
})
it('removes the nested contact section again when the name field is cleared', async () => {
const user = userEvent.setup()
render(<DemoForm />)
const nameInput = screen.getByRole('textbox', { name: /^name$/i })
await user.type(nameInput, 'Alice')
expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument()
await user.clear(nameInput)
await waitFor(() => {
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,151 @@
import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { DataSourceNotionWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NotionPageSelector from '@/app/components/base/notion-page-selector/base'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
const mockInvalidPreImportNotionPages = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockUsePreImportNotionPages = vi.fn()
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({
index,
size: 28,
start: index * 28,
})),
getTotalSize: () => count * 28 + 16,
}),
}))
vi.mock('@/service/knowledge/use-import', () => ({
usePreImportNotionPages: (params: { datasetId: string, credentialId: string }) => mockUsePreImportNotionPages(params),
useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) =>
selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
const buildCredential = (id: string, name: string, workspaceName: string): DataSourceCredential => ({
id,
name,
type: CredentialTypeEnum.OAUTH2,
is_default: false,
avatar_url: '',
credential: {
workspace_icon: '',
workspace_name: workspaceName,
},
})
const credentials: DataSourceCredential[] = [
buildCredential('c1', 'Cred 1', 'Workspace 1'),
buildCredential('c2', 'Cred 2', 'Workspace 2'),
]
const workspacePagesByCredential: Record<string, DataSourceNotionWorkspace[]> = {
c1: [
{
workspace_id: 'w1',
workspace_icon: '',
workspace_name: 'Workspace 1',
pages: [
{ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false },
{ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false },
{ page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true },
],
},
],
c2: [
{
workspace_id: 'w2',
workspace_icon: '',
workspace_name: 'Workspace 2',
pages: [
{ page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false },
],
},
],
}
describe('Base Notion Page Selector Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUsePreImportNotionPages.mockImplementation(({ credentialId }: { credentialId: string }) => ({
data: {
notion_info: workspacePagesByCredential[credentialId] ?? workspacePagesByCredential.c1,
},
isFetching: false,
isError: false,
}))
})
it('selects a page tree, filters through search, clears search, and previews the current page', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onPreview = vi.fn()
render(
<NotionPageSelector
credentialList={credentials}
onSelect={onSelect}
onPreview={onPreview}
previewPageId="root-1"
/>,
)
await user.click(screen.getByTestId('checkbox-notion-page-checkbox-root-1'))
expect(onSelect).toHaveBeenLastCalledWith(expect.arrayContaining([
expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }),
expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }),
expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }),
]))
await user.type(screen.getByTestId('notion-search-input'), 'missing-page')
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
await user.click(screen.getByTestId('notion-search-input-clear'))
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
await user.click(screen.getByTestId('notion-page-preview-root-1'))
expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }))
})
it('switches workspace credentials and opens the configuration entry point', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onSelectCredential = vi.fn()
render(
<NotionPageSelector
credentialList={credentials}
onSelect={onSelect}
onSelectCredential={onSelectCredential}
datasetId="dataset-1"
/>,
)
expect(onSelectCredential).toHaveBeenCalledWith('c1')
await user.click(screen.getByTestId('notion-credential-selector-btn'))
await user.click(screen.getByTestId('notion-credential-item-c2'))
expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' })
expect(onSelect).toHaveBeenCalledWith([])
await waitFor(() => {
expect(onSelectCredential).toHaveBeenLastCalledWith('c2')
expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
})
})

View File

@ -0,0 +1,191 @@
import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
import type { ComponentProps } from 'react'
import type { EventEmitterValue } from '@/context/event-emitter'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getNearestEditorFromDOMNode } from 'lexical'
import { useEffect } from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
import {
UPDATE_DATASETS_EVENT_EMITTER,
UPDATE_HISTORY_EVENT_EMITTER,
} from '@/app/components/base/prompt-editor/constants'
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
type Captures = {
eventEmitter: EventEmitter<EventEmitterValue> | null
events: EventEmitterValue[]
}
const EventProbe = ({ captures }: { captures: Captures }) => {
const { eventEmitter } = useEventEmitterContextContext()
useEffect(() => {
captures.eventEmitter = eventEmitter
}, [captures, eventEmitter])
eventEmitter?.useSubscription((value) => {
captures.events.push(value)
})
return <button type="button">outside</button>
}
const PromptEditorHarness = ({
captures,
...props
}: ComponentProps<typeof PromptEditor> & { captures: Captures }) => (
<EventEmitterContextProvider>
<EventProbe captures={captures} />
<PromptEditor {...props} />
</EventEmitterContextProvider>
)
describe('Base Prompt Editor Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Real prompt editor integration should emit block updates and transform editor updates into text output.
describe('Editor Shell', () => {
it('should render with the real editor, emit dataset/history events, and convert update events into text changes', async () => {
const captures: Captures = { eventEmitter: null, events: [] }
const onChange = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
const user = userEvent.setup()
const { rerender, container } = render(
<PromptEditorHarness
captures={captures}
instanceId="editor-1"
compact={true}
className="editor-shell"
placeholder="Type prompt"
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
contextBlock={{
show: false,
datasets: [{ id: 'ds-1', name: 'Dataset One', type: 'dataset' }],
}}
historyBlock={{
show: false,
history: { user: 'user-role', assistant: 'assistant-role' },
}}
/>,
)
expect(screen.getByText('Type prompt')).toBeInTheDocument()
await waitFor(() => {
expect(captures.eventEmitter).not.toBeNull()
})
const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
expect(editable).toBeInTheDocument()
await user.click(editable)
await waitFor(() => {
expect(onFocus).toHaveBeenCalledTimes(1)
})
await user.click(screen.getByRole('button', { name: 'outside' }))
await waitFor(() => {
expect(onBlur).toHaveBeenCalledTimes(1)
})
act(() => {
captures.eventEmitter?.emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'editor-1',
payload: 'first line\nsecond line',
})
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('first line\nsecond line')
})
expect(captures.events).toContainEqual({
type: UPDATE_DATASETS_EVENT_EMITTER,
payload: [{ id: 'ds-1', name: 'Dataset One', type: 'dataset' }],
})
expect(captures.events).toContainEqual({
type: UPDATE_HISTORY_EVENT_EMITTER,
payload: { user: 'user-role', assistant: 'assistant-role' },
})
rerender(
<PromptEditorHarness
captures={captures}
instanceId="editor-1"
contextBlock={{
show: false,
datasets: [{ id: 'ds-2', name: 'Dataset Two', type: 'dataset' }],
}}
historyBlock={{
show: false,
history: { user: 'user-next', assistant: 'assistant-next' },
}}
/>,
)
await waitFor(() => {
expect(captures.events).toContainEqual({
type: UPDATE_DATASETS_EVENT_EMITTER,
payload: [{ id: 'ds-2', name: 'Dataset Two', type: 'dataset' }],
})
})
expect(captures.events).toContainEqual({
type: UPDATE_HISTORY_EVENT_EMITTER,
payload: { user: 'user-next', assistant: 'assistant-next' },
})
})
it('should tolerate updates without onChange and rethrow lexical runtime errors through the configured handler', async () => {
const captures: Captures = { eventEmitter: null, events: [] }
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { container } = render(
<PromptEditorHarness
captures={captures}
instanceId="editor-2"
editable={false}
placeholder="Read only prompt"
/>,
)
await waitFor(() => {
expect(captures.eventEmitter).not.toBeNull()
})
act(() => {
captures.eventEmitter?.emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'editor-2',
payload: 'silent update',
})
})
const editable = container.querySelector('[contenteditable="false"]') as HTMLElement
const editor = getNearestEditorFromDOMNode(editable)
expect(editable).toBeInTheDocument()
expect(editor).not.toBeNull()
expect(screen.getByRole('textbox')).toHaveTextContent('silent update')
expect(() => {
act(() => {
editor?.update(() => {
throw new Error('prompt-editor boom')
})
})
}).toThrow('prompt-editor boom')
consoleErrorSpy.mockRestore()
})
})
})

View File

@ -0,0 +1,107 @@
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('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
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',
isCurrentWorkspaceManager: 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)
})
})

View File

@ -0,0 +1,182 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'
const {
mockPush,
mockLogout,
mockResetUser,
mockSetShowAccountSettingModal,
} = vi.hoisted(() => ({
mockPush: vi.fn(),
mockLogout: vi.fn(),
mockResetUser: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (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/app-context', () => ({
useAppContext: () => ({
userProfile: {
name: 'Ada Lovelace',
email: 'ada@example.com',
avatar_url: '',
},
langGeniusVersionInfo: {
current_version: '1.0.0',
latest_version: '1.1.0',
release_notes: 'https://example.com/releases/1.1.0',
},
isCurrentWorkspaceOwner: false,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isEducationAccount: false,
plan: {
type: Plan.professional,
},
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
const state = {
systemFeatures: {
branding: {
enabled: false,
workspace_logo: null,
},
},
}
return selector ? selector(state) : state
},
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/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 = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<AccountDropdown />
</QueryClientProvider>,
)
}
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.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('setup_status', 'done')
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('setup_status')).toBeNull()
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()
})
})

View File

@ -0,0 +1,237 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Nav from '@/app/components/header/nav'
import { AppModeEnum } from '@/types/app'
const mockPush = vi.fn()
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()
const mockOnLoadMore = vi.fn()
let mockSelectedSegment = 'app'
let mockIsCurrentWorkspaceEditor = true
vi.mock('@headlessui/react', () => {
type MenuContextValue = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
}
const MenuContext = React.createContext<MenuContextValue | null>(null)
const Menu = ({
children,
}: {
children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode)
}) => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<MenuContext.Provider value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
)
}
const MenuButton = ({
children,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const context = React.useContext(MenuContext)
return (
<button
type="button"
aria-expanded={context?.open ?? false}
onClick={(event) => {
context?.setOpen(v => !v)
onClick?.(event)
}}
{...props}
>
{children}
</button>
)
}
const MenuItems = ({
as: Component = 'div',
children,
...props
}: {
as?: React.ElementType
children: React.ReactNode
} & Record<string, unknown>) => {
const context = React.useContext(MenuContext)
if (!context?.open)
return null
return <Component {...props}>{children}</Component>
}
const MenuItem = ({
as: Component = 'div',
children,
...props
}: {
as?: React.ElementType
children: React.ReactNode
} & Record<string, unknown>) => <Component {...props}>{children}</Component>
return {
Menu,
MenuButton,
MenuItems,
MenuItem,
Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: () => mockSelectedSegment,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
}: {
href: string
children?: React.ReactNode
}) => <a href={href}>{children}</a>,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: () => mockSetAppDetail,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
}),
}))
const navigationItems = [
{
id: 'app-1',
name: 'Alpha',
link: '/app/app-1/configuration',
icon_type: 'emoji' as const,
icon: '🤖',
icon_background: '#FFEAD5',
icon_url: null,
mode: AppModeEnum.CHAT,
},
{
id: 'app-2',
name: 'Bravo',
link: '/app/app-2/workflow',
icon_type: 'emoji' as const,
icon: '⚙️',
icon_background: '#E0F2FE',
icon_url: null,
mode: AppModeEnum.WORKFLOW,
},
]
const curNav = {
id: 'app-1',
name: 'Alpha',
icon_type: 'emoji' as const,
icon: '🤖',
icon_background: '#FFEAD5',
icon_url: null,
mode: AppModeEnum.CHAT,
}
const renderNav = (nav = curNav) => {
return render(
<Nav
isApp
icon={<span data-testid="nav-icon">icon</span>}
activeIcon={<span data-testid="nav-icon-active">active-icon</span>}
text="menus.apps"
activeSegment={['apps', 'app']}
link="/apps"
curNav={nav}
navigationItems={navigationItems}
createText="menus.newApp"
onCreate={mockOnCreate}
onLoadMore={mockOnLoadMore}
isLoadingMore={false}
/>,
)
}
describe('Header Nav Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectedSegment = 'app'
mockIsCurrentWorkspaceEditor = true
})
it('switches to another app from the selector and clears stale app detail first', async () => {
renderNav()
fireEvent.click(screen.getByRole('button', { name: /Alpha/i }))
fireEvent.click(await screen.findByText('Bravo'))
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/app/app-2/workflow')
})
it('opens the nested create menu and emits all app creation branches', async () => {
renderNav()
fireEvent.click(screen.getByRole('button', { name: /Alpha/i }))
fireEvent.click(await screen.findByText('menus.newApp'))
fireEvent.click(await screen.findByText('newApp.startFromBlank'))
fireEvent.click(await screen.findByText('newApp.startFromTemplate'))
fireEvent.click(await screen.findByText('importDSL'))
expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank')
expect(mockOnCreate).toHaveBeenNthCalledWith(2, 'template')
expect(mockOnCreate).toHaveBeenNthCalledWith(3, 'dsl')
})
it('keeps the current nav label in sync with prop updates', async () => {
const { rerender } = renderNav()
expect(screen.getByRole('button', { name: /Alpha/i })).toBeInTheDocument()
rerender(
<Nav
isApp
icon={<span data-testid="nav-icon">icon</span>}
activeIcon={<span data-testid="nav-icon-active">active-icon</span>}
text="menus.apps"
activeSegment={['apps', 'app']}
link="/apps"
curNav={{
...curNav,
name: 'Alpha Renamed',
}}
navigationItems={navigationItems}
createText="menus.newApp"
onCreate={mockOnCreate}
onLoadMore={mockOnLoadMore}
isLoadingMore={false}
/>,
)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Alpha Renamed/i })).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,163 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginPage from '@/app/components/plugins/plugin-page'
import { renderWithNuqs } from '@/test/nuqs-testing'
const mockFetchManifestFromMarketPlace = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils')>()
return {
...actual,
sleep: vi.fn(() => Promise.resolve()),
}
})
vi.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
enable_marketplace: true,
plugin_installation_permission: {
restrict_to_marketplace_only: false,
},
},
}),
}))
vi.mock('@/service/use-plugins', () => ({
useReferenceSettings: () => ({
data: {
permission: {
install_permission: 'everyone',
debug_permission: 'noOne',
},
},
}),
useMutationReferenceSettings: () => ({
mutate: vi.fn(),
isPending: false,
}),
useInvalidateReferenceSettings: () => vi.fn(),
useInstalledPluginList: () => ({
data: {
total: 2,
},
}),
}))
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: (...args: unknown[]) => mockFetchManifestFromMarketPlace(...args),
fetchBundleInfoFromMarketPlace: vi.fn(),
}))
vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({
default: () => <div data-testid="plugin-tasks">plugin tasks</div>,
}))
vi.mock('@/app/components/plugins/plugin-page/debug-info', () => ({
default: () => <div data-testid="debug-info">debug info</div>,
}))
vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({
default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => (
<button type="button" data-testid="install-plugin-dropdown" onClick={onSwitchToMarketplaceTab}>
install plugin
</button>
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({
uniqueIdentifier,
onClose,
}: {
uniqueIdentifier: string
onClose: () => void
}) => (
<div data-testid="install-from-marketplace-modal">
<span>{uniqueIdentifier}</span>
<button type="button" onClick={onClose}>close-install-modal</button>
</div>
),
}))
const renderPluginPage = (searchParams = '') => {
return renderWithNuqs(
<PluginPage
plugins={<div data-testid="plugins-view">plugins view</div>}
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
/>,
{ searchParams },
)
}
describe('Plugin Page Shell Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchManifestFromMarketPlace.mockResolvedValue({
data: {
plugin: {
org: 'langgenius',
name: 'plugin-demo',
},
version: {
version: '1.0.0',
},
},
})
})
it('switches from installed plugins to marketplace and syncs the active tab into the URL', async () => {
const { onUrlUpdate } = renderPluginPage()
expect(screen.getByTestId('plugins-view')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-view')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('tab-item-discover'))
await waitFor(() => {
expect(screen.getByTestId('marketplace-view')).toBeInTheDocument()
})
const tabUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(tabUpdate.searchParams.get('tab')).toBe('discover')
})
it('hydrates marketplace installation from query params and clears the install state when closed', async () => {
const { onUrlUpdate } = renderPluginPage('?package-ids=%5B%22langgenius%2Fplugin-demo%22%5D')
await waitFor(() => {
expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('langgenius%2Fplugin-demo')
expect(screen.getByTestId('install-from-marketplace-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'close-install-modal' }))
await waitFor(() => {
const clearUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(clearUpdate.searchParams.has('package-ids')).toBe(false)
})
})
})

View File

@ -0,0 +1,155 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TextGeneration from '@/app/components/share/text-generation'
const useSearchParamsMock = vi.fn(() => new URLSearchParams())
const mockUseTextGenerationAppState = vi.fn()
const mockUseTextGenerationBatch = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: () => useSearchParamsMock(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'pc',
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
}))
vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-app-state', () => ({
useTextGenerationAppState: (...args: unknown[]) => mockUseTextGenerationAppState(...args),
}))
vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-batch', () => ({
useTextGenerationBatch: (...args: unknown[]) => mockUseTextGenerationBatch(...args),
}))
vi.mock('@/app/components/share/text-generation/text-generation-sidebar', () => ({
default: ({
currentTab,
onTabChange,
}: {
currentTab: string
onTabChange: (tab: string) => void
}) => (
<div data-testid="text-generation-sidebar">
<span data-testid="current-tab">{currentTab}</span>
<button type="button" onClick={() => onTabChange('batch')}>switch-to-batch</button>
<button type="button" onClick={() => onTabChange('create')}>switch-to-create</button>
</div>
),
}))
vi.mock('@/app/components/share/text-generation/text-generation-result-panel', () => ({
default: ({
isCallBatchAPI,
resultExisted,
}: {
isCallBatchAPI: boolean
resultExisted: boolean
}) => (
<div
data-testid="text-generation-result-panel"
data-batch={String(isCallBatchAPI)}
data-result={String(resultExisted)}
/>
),
}))
const createReadyAppState = () => ({
accessMode: 'public',
appId: 'app-123',
appSourceType: 'published',
customConfig: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
handleRemoveSavedMessage: vi.fn(),
handleSaveMessage: vi.fn(),
moreLikeThisConfig: {
enabled: true,
},
promptConfig: {
user_input_form: [],
},
savedMessages: [],
siteInfo: {
title: 'Text Generation',
},
systemFeatures: {
branding: {
enabled: false,
workspace_logo: null,
},
},
textToSpeechConfig: {
enabled: true,
},
visionConfig: null,
})
const createBatchState = () => ({
allFailedTaskList: [],
allSuccessTaskList: [],
allTaskList: [],
allTasksRun: false,
controlRetry: 0,
exportRes: vi.fn(),
handleCompleted: vi.fn(),
handleRetryAllFailedTask: vi.fn(),
handleRunBatch: vi.fn(),
isCallBatchAPI: false,
noPendingTask: true,
resetBatchExecution: vi.fn(),
setIsCallBatchAPI: vi.fn(),
showTaskList: false,
})
describe('Text Generation Mode Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
useSearchParamsMock.mockReturnValue(new URLSearchParams())
mockUseTextGenerationAppState.mockReturnValue(createReadyAppState())
mockUseTextGenerationBatch.mockReturnValue(createBatchState())
})
it('shows the loading state before app metadata is ready', () => {
mockUseTextGenerationAppState.mockReturnValue({
...createReadyAppState(),
appId: '',
promptConfig: null,
siteInfo: null,
})
render(<TextGeneration />)
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('hydrates the initial tab from the mode query parameter and lets the sidebar switch it', () => {
useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=batch'))
render(<TextGeneration />)
expect(screen.getByTestId('current-tab')).toHaveTextContent('batch')
fireEvent.click(screen.getByRole('button', { name: 'switch-to-create' }))
expect(screen.getByTestId('current-tab')).toHaveTextContent('create')
})
it('falls back to create mode for unsupported query values', () => {
useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=unsupported'))
render(<TextGeneration />)
expect(screen.getByTestId('current-tab')).toHaveTextContent('create')
expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute('data-batch', 'false')
})
})

View File

@ -0,0 +1,205 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProviderList from '@/app/components/tools/provider-list'
import { CollectionType } from '@/app/components/tools/types'
import { renderWithNuqs } from '@/test/nuqs-testing'
const mockInvalidateInstalledPluginList = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
systemFeatures: {
enable_marketplace: true,
},
}),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
getTagLabel: (name: string) => name,
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: [
{
id: 'builtin-plugin',
name: 'plugin-tool',
author: 'Dify',
description: { en_US: 'Plugin Tool' },
icon: 'icon-plugin',
label: { en_US: 'Plugin Tool' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['search'],
plugin_id: 'langgenius/plugin-tool',
},
{
id: 'builtin-basic',
name: 'basic-tool',
author: 'Dify',
description: { en_US: 'Basic Tool' },
icon: 'icon-basic',
label: { en_US: 'Basic Tool' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['utility'],
},
],
refetch: vi.fn(),
}),
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: ({ enabled }: { enabled: boolean }) => ({
data: enabled
? {
plugins: [{
plugin_id: 'langgenius/plugin-tool',
declaration: {
category: 'tool',
},
}],
}
: null,
}),
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
}))
vi.mock('@/app/components/tools/labels/filter', () => ({
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
<div data-testid="tool-label-filter">
<button type="button" onClick={() => onChange(['search'])}>apply-search-filter</button>
</div>
),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, className }: { payload: { name: string }, className?: string }) => (
<div data-testid={`tool-card-${payload.name}`} className={className}>
{payload.name}
</div>
),
}))
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
default: ({ tags }: { tags: string[] }) => <div data-testid="tool-card-more-info">{tags.join(',')}</div>,
}))
vi.mock('@/app/components/tools/provider/detail', () => ({
default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => (
<div data-testid="tool-provider-detail">
<span>{collection.name}</span>
<button type="button" onClick={onHide}>close-provider-detail</button>
</div>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
default: ({
detail,
onHide,
onUpdate,
}: {
detail?: { plugin_id: string }
onHide: () => void
onUpdate: () => void
}) => detail
? (
<div data-testid="tool-plugin-detail-panel">
<span>{detail.plugin_id}</span>
<button type="button" onClick={onUpdate}>update-plugin-detail</button>
<button type="button" onClick={onHide}>close-plugin-detail</button>
</div>
)
: null,
}))
vi.mock('@/app/components/tools/provider/empty', () => ({
default: () => <div data-testid="workflow-empty">workflow empty</div>,
}))
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
default: ({ text }: { text: string }) => <div data-testid="tools-empty">{text}</div>,
}))
vi.mock('@/app/components/tools/marketplace', () => ({
default: ({
isMarketplaceArrowVisible,
showMarketplacePanel,
}: {
isMarketplaceArrowVisible: boolean
showMarketplacePanel: () => void
}) => (
<button type="button" data-testid="marketplace-arrow" data-visible={String(isMarketplaceArrowVisible)} onClick={showMarketplacePanel}>
marketplace-arrow
</button>
),
}))
vi.mock('@/app/components/tools/marketplace/hooks', () => ({
useMarketplace: () => ({
handleScroll: vi.fn(),
}),
}))
vi.mock('@/app/components/tools/mcp', () => ({
default: ({ searchText }: { searchText: string }) => <div data-testid="mcp-list">{searchText}</div>,
}))
const renderProviderList = (searchParams = '') => {
return renderWithNuqs(<ProviderList />, { searchParams })
}
describe('Tool Provider List Shell Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
Element.prototype.scrollTo = vi.fn()
})
it('opens a plugin-backed provider detail panel and invalidates installed plugins on update', async () => {
renderProviderList('?category=builtin')
fireEvent.click(screen.getByTestId('tool-card-plugin-tool'))
await waitFor(() => {
expect(screen.getByTestId('tool-plugin-detail-panel')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'update-plugin-detail' }))
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'close-plugin-detail' }))
await waitFor(() => {
expect(screen.queryByTestId('tool-plugin-detail-panel')).not.toBeInTheDocument()
})
})
it('scrolls to the marketplace section and syncs workflow tab selection into the URL', async () => {
const { onUrlUpdate } = renderProviderList('?category=builtin')
fireEvent.click(screen.getByTestId('marketplace-arrow'))
expect(Element.prototype.scrollTo).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('tab-item-workflow'))
await waitFor(() => {
expect(screen.getByTestId('workflow-empty')).toBeInTheDocument()
})
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('category')).toBe('workflow')
})
})

View File

@ -18,6 +18,7 @@ const mockInvalidDatasetDetail = vi.fn()
const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const mockToast = vi.fn()
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -111,6 +112,10 @@ vi.mock('@/service/datasets', () => ({
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: (...args: unknown[]) => mockToast(...args),
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
default: ({
show,
@ -225,4 +230,49 @@ describe('Dropdown callback coverage', () => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should show the used-by-app confirmation copy when the dataset is referenced by apps', async () => {
const user = userEvent.setup()
mockCheckIsUsedInApp.mockResolvedValueOnce({ is_using: true })
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
expect(screen.getByText('dataset.datasetUsedByApp')).toBeInTheDocument()
})
})
it('should surface an export failure toast when pipeline export fails', async () => {
const user = userEvent.setup()
mockExportPipeline.mockRejectedValueOnce(new Error('export failed'))
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' })
})
})
it('should surface the backend message when checking app usage fails', async () => {
const user = userEvent.setup()
mockCheckIsUsedInApp.mockRejectedValueOnce({
json: vi.fn().mockResolvedValue({ message: 'check failed' }),
})
render(<Dropdown expand />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByText('common.operation.delete'))
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' })
})
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
import type { DataSet } from '@/models/datasets'
import { RiEditLine } from '@remixicon/react'
import { render, screen, waitFor } from '@testing-library/react'
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import {
@ -218,6 +218,31 @@ describe('MenuItem', () => {
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should stop propagation before invoking the handler', () => {
const parentClick = vi.fn()
const handleClick = vi.fn()
render(
<div onClick={parentClick}>
<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />
</div>,
)
fireEvent.click(screen.getByText('Edit'))
expect(handleClick).toHaveBeenCalledTimes(1)
expect(parentClick).not.toHaveBeenCalled()
})
it('should not crash when no click handler is provided', () => {
render(<MenuItem name="Edit" Icon={RiEditLine} />)
const event = createEvent.click(screen.getByText('Edit'))
fireEvent(screen.getByText('Edit'), event)
expect(event.defaultPrevented).toBe(true)
})
})
})
@ -265,6 +290,47 @@ describe('Menu', () => {
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should invoke the rename callback when edit is clicked', async () => {
const user = userEvent.setup()
const openRenameModal = vi.fn()
render(
<Menu
showDelete
openRenameModal={openRenameModal}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
await user.click(screen.getByText('common.operation.edit'))
expect(openRenameModal).toHaveBeenCalledTimes(1)
})
it('should invoke export and delete callbacks from their menu items', async () => {
const user = userEvent.setup()
const handleExportPipeline = vi.fn()
const detectIsUsedByApp = vi.fn()
render(
<Menu
showDelete
openRenameModal={vi.fn()}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>,
)
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
})
})
})
describe('Dropdown', () => {

View File

@ -0,0 +1,24 @@
import { render } from '@testing-library/react'
import { AppCardSkeleton } from '../app-card-skeleton'
describe('AppCardSkeleton', () => {
it('should render six skeleton cards by default', () => {
const { container } = render(<AppCardSkeleton />)
expect(container.childElementCount).toBe(6)
expect(AppCardSkeleton.displayName).toBe('AppCardSkeleton')
})
it('should respect the custom skeleton count and card classes', () => {
const { container } = render(<AppCardSkeleton count={2} />)
expect(container.childElementCount).toBe(2)
expect(container.firstElementChild).toHaveClass(
'h-[160px]',
'rounded-xl',
'border-[0.5px]',
'bg-components-card-bg',
'p-4',
)
})
})

View File

@ -0,0 +1,144 @@
import type { IChatItem } from '../type'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchAgentLogDetail } from '@/service/log'
import ChatLogModals from '../chat-log-modals'
vi.mock('@/service/log', () => ({
fetchAgentLogDetail: vi.fn(),
}))
describe('ChatLogModals', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: { id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'] })
})
// Modal visibility should follow the two booleans unless log modals are globally hidden.
describe('Rendering', () => {
it('should render real prompt and agent log modals when enabled', async () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
render(
<ChatLogModals
width={480}
currentLogItem={{
id: 'log-1',
isAnswer: true,
content: 'reply',
input: { question: 'hello' },
log: [{ role: 'user', text: 'Prompt body' }],
conversationId: 'conversation-1',
} as IChatItem}
showPromptLogModal={true}
showAgentLogModal={true}
setCurrentLogItem={vi.fn()}
setShowPromptLogModal={vi.fn()}
setShowAgentLogModal={vi.fn()}
/>,
)
expect(screen.getByText('PROMPT LOG')).toBeInTheDocument()
expect(screen.getByText('Prompt body')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument()
})
})
it('should render nothing when hideLogModal is true', () => {
render(
<ChatLogModals
width={320}
currentLogItem={{
id: 'log-2',
isAnswer: true,
content: 'reply',
log: [{ role: 'user', text: 'Prompt body' }],
conversationId: 'conversation-2',
} as IChatItem}
showPromptLogModal={true}
showAgentLogModal={true}
hideLogModal={true}
setCurrentLogItem={vi.fn()}
setShowPromptLogModal={vi.fn()}
setShowAgentLogModal={vi.fn()}
/>,
)
expect(screen.queryByText('PROMPT LOG')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).not.toBeInTheDocument()
})
})
// Cancel actions should clear the current item and close only the targeted modal.
describe('User Interactions', () => {
it('should close the prompt log modal through the real close action', async () => {
const user = userEvent.setup()
const setCurrentLogItem = vi.fn()
const setShowPromptLogModal = vi.fn()
const setShowAgentLogModal = vi.fn()
render(
<ChatLogModals
width={480}
currentLogItem={{
id: 'log-3',
isAnswer: true,
content: 'reply',
input: { question: 'hello' },
log: [{ role: 'user', text: 'Prompt body' }],
} as IChatItem}
showPromptLogModal={true}
showAgentLogModal={false}
setCurrentLogItem={setCurrentLogItem}
setShowPromptLogModal={setShowPromptLogModal}
setShowAgentLogModal={setShowAgentLogModal}
/>,
)
await user.click(screen.getByTestId('close-btn-container'))
expect(setCurrentLogItem).toHaveBeenCalled()
expect(setShowPromptLogModal).toHaveBeenCalledWith(false)
expect(setShowAgentLogModal).not.toHaveBeenCalled()
})
it('should close the agent log modal through the real close action', async () => {
const user = userEvent.setup()
const setCurrentLogItem = vi.fn()
const setShowPromptLogModal = vi.fn()
const setShowAgentLogModal = vi.fn()
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
render(
<ChatLogModals
width={480}
currentLogItem={{
id: 'log-4',
isAnswer: true,
content: 'reply',
input: { question: 'hello' },
log: [{ role: 'user', text: 'Prompt body' }],
conversationId: 'conversation-4',
} as IChatItem}
showPromptLogModal={false}
showAgentLogModal={true}
setCurrentLogItem={setCurrentLogItem}
setShowPromptLogModal={setShowPromptLogModal}
setShowAgentLogModal={setShowAgentLogModal}
/>,
)
await waitFor(() => {
expect(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })).toBeInTheDocument()
})
await user.click(screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling as HTMLElement)
expect(setCurrentLogItem).toHaveBeenCalled()
expect(setShowAgentLogModal).toHaveBeenCalledWith(false)
expect(setShowPromptLogModal).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,293 @@
import type { ChatItem } from '../../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest'
import { useChatLayout } from '../use-chat-layout'
type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
let capturedResizeCallbacks: ResizeCallback[] = []
let disconnectSpy: ReturnType<typeof vi.fn>
let rafCallbacks: FrameRequestCallback[] = []
const makeChatItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
id: `item-${Math.random().toString(36).slice(2)}`,
content: 'Test content',
isAnswer: false,
...overrides,
})
const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({
borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
contentRect: new DOMRect(0, 0, inlineSize, blockSize),
devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
target: document.createElement('div'),
})
const assignMetric = (node: HTMLElement, key: 'clientWidth' | 'clientHeight' | 'scrollHeight', value: number) => {
Object.defineProperty(node, key, {
configurable: true,
value,
})
}
const LayoutHarness = ({
chatList,
sidebarCollapseState,
attachRefs = true,
}: {
chatList: ChatItem[]
sidebarCollapseState?: boolean
attachRefs?: boolean
}) => {
const {
width,
chatContainerRef,
chatContainerInnerRef,
chatFooterRef,
chatFooterInnerRef,
} = useChatLayout({ chatList, sidebarCollapseState })
return (
<>
<div
data-testid="chat-container"
ref={(node) => {
chatContainerRef.current = attachRefs ? node : null
if (node && attachRefs) {
assignMetric(node, 'clientWidth', 400)
assignMetric(node, 'clientHeight', 240)
assignMetric(node, 'scrollHeight', 640)
if (!node.dataset.metricsReady) {
node.scrollTop = 0
node.dataset.metricsReady = 'true'
}
}
}}
>
<div
data-testid="chat-container-inner"
ref={(node) => {
chatContainerInnerRef.current = attachRefs ? node : null
if (node && attachRefs)
assignMetric(node, 'clientWidth', 360)
}}
/>
</div>
<div
data-testid="chat-footer"
ref={(node) => {
chatFooterRef.current = attachRefs ? node : null
}}
>
<div
data-testid="chat-footer-inner"
ref={(node) => {
chatFooterInnerRef.current = attachRefs ? node : null
}}
/>
</div>
<output data-testid="layout-width">{width}</output>
</>
)
}
const flushAnimationFrames = () => {
const queuedCallbacks = [...rafCallbacks]
rafCallbacks = []
queuedCallbacks.forEach(callback => callback(0))
}
describe('useChatLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
capturedResizeCallbacks = []
disconnectSpy = vi.fn()
rafCallbacks = []
Object.defineProperty(document.body, 'clientWidth', {
configurable: true,
value: 1024,
})
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('ResizeObserver', class {
constructor(cb: ResizeCallback) {
capturedResizeCallbacks.push(cb)
}
observe() { }
unobserve() { }
disconnect = disconnectSpy
})
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
// The hook should compute shell dimensions and auto-scroll when enough chat items exist.
describe('Layout Calculation', () => {
it('should auto-scroll and compute the chat shell widths on mount', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
render(
<LayoutHarness
chatList={[
makeChatItem({ id: 'q1' }),
makeChatItem({ id: 'a1', isAnswer: true }),
]}
sidebarCollapseState={false}
/>,
)
act(() => {
flushAnimationFrames()
vi.runAllTimers()
})
expect(screen.getByTestId('layout-width')).toHaveTextContent('600')
expect(screen.getByTestId('chat-footer').style.width).toBe('400px')
expect(screen.getByTestId('chat-footer-inner').style.width).toBe('360px')
expect((screen.getByTestId('chat-container') as HTMLDivElement).scrollTop).toBe(640)
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function))
})
})
// Resize observers should keep padding and widths in sync, then fully clean up on unmount.
describe('Resize Observers', () => {
it('should react to observer updates and disconnect both observers on unmount', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const { unmount } = render(
<LayoutHarness
chatList={[
makeChatItem({ id: 'q1' }),
makeChatItem({ id: 'a1', isAnswer: true }),
]}
/>,
)
act(() => {
capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver)
})
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
act(() => {
capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver)
})
expect(screen.getByTestId('chat-footer').style.width).toBe('560px')
unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
expect(disconnectSpy).toHaveBeenCalledTimes(2)
})
it('should respect manual scrolling until a new first message arrives and safely ignore missing refs', () => {
const { rerender } = render(
<LayoutHarness
chatList={[
makeChatItem({ id: 'q1' }),
makeChatItem({ id: 'a1', isAnswer: true }),
]}
/>,
)
const container = screen.getByTestId('chat-container') as HTMLDivElement
act(() => {
fireEvent.scroll(container)
flushAnimationFrames()
})
act(() => {
container.scrollTop = 10
fireEvent.scroll(container)
})
rerender(
<LayoutHarness
chatList={[
makeChatItem({ id: 'q1' }),
makeChatItem({ id: 'a1', isAnswer: true }),
makeChatItem({ id: 'a2', isAnswer: true }),
]}
/>,
)
act(() => {
flushAnimationFrames()
vi.runAllTimers()
})
act(() => {
container.scrollTop = 420
fireEvent.scroll(container)
})
rerender(
<LayoutHarness
chatList={[
makeChatItem({ id: 'q2' }),
makeChatItem({ id: 'a3', isAnswer: true }),
]}
/>,
)
act(() => {
flushAnimationFrames()
vi.runAllTimers()
})
expect(container.scrollTop).toBe(640)
rerender(
<LayoutHarness
chatList={[
makeChatItem({ id: 'q2' }),
makeChatItem({ id: 'a3', isAnswer: true }),
]}
attachRefs={false}
/>,
)
act(() => {
fireEvent.scroll(container)
flushAnimationFrames()
})
})
it('should keep the hook stable when the DOM refs are not attached', () => {
render(
<LayoutHarness
chatList={[makeChatItem({ id: 'q1' })]}
sidebarCollapseState={true}
attachRefs={false}
/>,
)
act(() => {
flushAnimationFrames()
vi.runAllTimers()
})
expect(screen.getByTestId('layout-width')).toHaveTextContent('0')
expect(capturedResizeCallbacks).toHaveLength(0)
expect(screen.getByTestId('chat-footer').style.width).toBe('')
expect(screen.getByTestId('chat-footer-inner').style.width).toBe('')
})
})
})

View File

@ -0,0 +1,56 @@
import type { FC } from 'react'
import type { IChatItem } from './type'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
type ChatLogModalsProps = {
width: number
currentLogItem?: IChatItem
showPromptLogModal: boolean
showAgentLogModal: boolean
hideLogModal?: boolean
setCurrentLogItem: (item?: IChatItem) => void
setShowPromptLogModal: (showPromptLogModal: boolean) => void
setShowAgentLogModal: (showAgentLogModal: boolean) => void
}
const ChatLogModals: FC<ChatLogModalsProps> = ({
width,
currentLogItem,
showPromptLogModal,
showAgentLogModal,
hideLogModal,
setCurrentLogItem,
setShowPromptLogModal,
setShowAgentLogModal,
}) => {
if (hideLogModal)
return null
return (
<>
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</>
)
}
export default ChatLogModals

View File

@ -13,26 +13,19 @@ import type {
import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { AppData } from '@/models/share'
import { debounce } from 'es-toolkit/compat'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Button from '@/app/components/base/button'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { cn } from '@/utils/classnames'
import Answer from './answer'
import ChatInputArea from './chat-input-area'
import ChatLogModals from './chat-log-modals'
import { ChatContextProvider } from './context-provider'
import Question from './question'
import TryToAsk from './try-to-ask'
import { useChatLayout } from './use-chat-layout'
export type ChatProps = {
isTryApp?: boolean
@ -133,128 +126,17 @@ const Chat: FC<ChatProps> = ({
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
const [width, setWidth] = useState(0)
const chatContainerRef = useRef<HTMLDivElement>(null)
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(null)
const chatFooterInnerRef = useRef<HTMLDivElement>(null)
const userScrolledRef = useRef(false)
const isAutoScrollingRef = useRef(false)
const handleScrollToBottom = useCallback(() => {
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) {
isAutoScrollingRef.current = true
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
requestAnimationFrame(() => {
isAutoScrollingRef.current = false
})
}
}, [chatList.length])
const handleWindowResize = useCallback(() => {
if (chatContainerRef.current)
setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8)
if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
}, [])
useEffect(() => {
handleScrollToBottom()
handleWindowResize()
}, [handleScrollToBottom, handleWindowResize])
useEffect(() => {
/* v8 ignore next - @preserve */
if (chatContainerRef.current) {
requestAnimationFrame(() => {
handleScrollToBottom()
handleWindowResize()
})
}
const {
width,
chatContainerRef,
chatContainerInnerRef,
chatFooterRef,
chatFooterInnerRef,
} = useChatLayout({
chatList,
sidebarCollapseState,
})
useEffect(() => {
const debouncedHandler = debounce(handleWindowResize, 200)
window.addEventListener('resize', debouncedHandler)
return () => {
window.removeEventListener('resize', debouncedHandler)
debouncedHandler.cancel()
}
}, [handleWindowResize])
useEffect(() => {
/* v8 ignore next - @preserve */
if (chatFooterRef.current && chatContainerRef.current) {
const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
handleScrollToBottom()
}
})
resizeContainerObserver.observe(chatFooterRef.current)
const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]
chatFooterRef.current!.style.width = `${inlineSize}px`
}
})
resizeFooterObserver.observe(chatContainerRef.current)
return () => {
resizeContainerObserver.disconnect()
resizeFooterObserver.disconnect()
}
}
}, [handleScrollToBottom])
useEffect(() => {
const setUserScrolled = () => {
const container = chatContainerRef.current
/* v8 ignore next 2 - @preserve */
if (!container)
return
/* v8 ignore next 2 - @preserve */
if (isAutoScrollingRef.current)
return
const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop
const SCROLL_UP_THRESHOLD = 100
userScrolledRef.current = distanceToBottom > SCROLL_UP_THRESHOLD
}
const container = chatContainerRef.current
/* v8 ignore next 2 - @preserve */
if (!container)
return
container.addEventListener('scroll', setUserScrolled)
return () => container.removeEventListener('scroll', setUserScrolled)
}, [])
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
useEffect(() => {
const firstMessageId = chatList[0]?.id
if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId))
userScrolledRef.current = false
prevFirstMessageIdRef.current = firstMessageId
}, [chatList])
useEffect(() => {
if (!sidebarCollapseState) {
const timer = setTimeout(handleWindowResize, 200)
return () => clearTimeout(timer)
}
}, [handleWindowResize, sidebarCollapseState])
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
return (
@ -279,7 +161,7 @@ const Chat: FC<ChatProps> = ({
<div
data-testid="chat-container"
ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
className={cn('relative h-full overflow-x-hidden overflow-y-auto', isTryApp && 'h-0 grow', chatContainerClassName)}
>
{chatNode}
<div
@ -338,7 +220,7 @@ const Chat: FC<ChatProps> = ({
!noStopResponding && isResponding && (
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
<div className="i-custom-vender-solid-mediaAndDevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<div className="mr-[5px] i-custom-vender-solid-mediaAndDevices-stop-circle h-3.5 w-3.5" />
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
</Button>
</div>
@ -375,26 +257,16 @@ const Chat: FC<ChatProps> = ({
}
</div>
</div>
{showPromptLogModal && !hideLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && !hideLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
<ChatLogModals
width={width}
currentLogItem={currentLogItem}
showPromptLogModal={showPromptLogModal}
showAgentLogModal={showAgentLogModal}
hideLogModal={hideLogModal}
setCurrentLogItem={setCurrentLogItem}
setShowPromptLogModal={setShowPromptLogModal}
setShowAgentLogModal={setShowAgentLogModal}
/>
</div>
</ChatContextProvider>
)

View File

@ -0,0 +1,144 @@
import type { ChatItem } from '../types'
import { debounce } from 'es-toolkit/compat'
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type UseChatLayoutOptions = {
chatList: ChatItem[]
sidebarCollapseState?: boolean
}
export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => {
const [width, setWidth] = useState(0)
const chatContainerRef = useRef<HTMLDivElement>(null)
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(null)
const chatFooterInnerRef = useRef<HTMLDivElement>(null)
const userScrolledRef = useRef(false)
const isAutoScrollingRef = useRef(false)
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
const handleScrollToBottom = useCallback(() => {
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) {
isAutoScrollingRef.current = true
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
requestAnimationFrame(() => {
isAutoScrollingRef.current = false
})
}
}, [chatList.length])
const handleWindowResize = useCallback(() => {
if (chatContainerRef.current)
setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8)
if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
}, [])
useEffect(() => {
handleScrollToBottom()
const animationFrame = requestAnimationFrame(handleWindowResize)
return () => {
cancelAnimationFrame(animationFrame)
}
}, [handleScrollToBottom, handleWindowResize])
useEffect(() => {
if (chatContainerRef.current) {
requestAnimationFrame(() => {
handleScrollToBottom()
handleWindowResize()
})
}
})
useEffect(() => {
const debouncedHandler = debounce(handleWindowResize, 200)
window.addEventListener('resize', debouncedHandler)
return () => {
window.removeEventListener('resize', debouncedHandler)
debouncedHandler.cancel()
}
}, [handleWindowResize])
useEffect(() => {
if (chatFooterRef.current && chatContainerRef.current) {
const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
handleScrollToBottom()
}
})
resizeContainerObserver.observe(chatFooterRef.current)
const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]
chatFooterRef.current!.style.width = `${inlineSize}px`
}
})
resizeFooterObserver.observe(chatContainerRef.current)
return () => {
resizeContainerObserver.disconnect()
resizeFooterObserver.disconnect()
}
}
}, [handleScrollToBottom])
useEffect(() => {
const setUserScrolled = () => {
const container = chatContainerRef.current
if (!container)
return
if (isAutoScrollingRef.current)
return
const distanceToBottom = container.scrollHeight - container.clientHeight - container.scrollTop
const scrollUpThreshold = 100
userScrolledRef.current = distanceToBottom > scrollUpThreshold
}
const container = chatContainerRef.current
if (!container)
return
container.addEventListener('scroll', setUserScrolled)
return () => container.removeEventListener('scroll', setUserScrolled)
}, [])
useEffect(() => {
const firstMessageId = chatList[0]?.id
if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId))
userScrolledRef.current = false
prevFirstMessageIdRef.current = firstMessageId
}, [chatList])
useEffect(() => {
if (!sidebarCollapseState) {
const timer = setTimeout(handleWindowResize, 200)
return () => clearTimeout(timer)
}
}, [handleWindowResize, sidebarCollapseState])
return {
width,
chatContainerRef,
chatContainerInnerRef,
chatFooterRef,
chatFooterInnerRef,
}
}

View File

@ -0,0 +1,113 @@
import type { ComponentProps } from 'react'
import type { NotionPageRow } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PageRow from '../page-row'
const buildRow = (overrides: Partial<NotionPageRow> = {}): NotionPageRow => ({
page: {
page_id: 'page-1',
page_name: 'Page 1',
parent_id: 'root',
page_icon: null,
type: 'page',
is_bound: false,
},
parentExists: false,
depth: 0,
expand: false,
hasChild: false,
ancestors: [],
...overrides,
})
const renderPageRow = (overrides: Partial<ComponentProps<typeof PageRow>> = {}) => {
const props: ComponentProps<typeof PageRow> = {
checked: false,
disabled: false,
isPreviewed: false,
onPreview: vi.fn(),
onSelect: vi.fn(),
onToggle: vi.fn(),
row: buildRow(),
searchValue: '',
selectionMode: 'multiple',
showPreview: true,
style: { height: 28 },
...overrides,
}
return {
...render(<PageRow {...props} />),
props,
}
}
describe('PageRow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call onSelect with the page id when the checkbox is clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
renderPageRow({ onSelect })
await user.click(screen.getByTestId('checkbox-notion-page-checkbox-page-1'))
expect(onSelect).toHaveBeenCalledWith('page-1')
})
it('should call onToggle when the row has children and the toggle is clicked', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
renderPageRow({
onToggle,
row: buildRow({
hasChild: true,
expand: true,
}),
})
await user.click(screen.getByTestId('notion-page-toggle-page-1'))
expect(onToggle).toHaveBeenCalledWith('page-1')
})
it('should render breadcrumbs and hide the toggle while searching', () => {
renderPageRow({
searchValue: 'Page',
row: buildRow({
parentExists: true,
ancestors: ['Workspace', 'Section'],
}),
})
expect(screen.queryByTestId('notion-page-toggle-page-1')).not.toBeInTheDocument()
expect(screen.getByText('Workspace / Section / Page 1')).toBeInTheDocument()
})
it('should render preview state and call onPreview when the preview button is clicked', async () => {
const user = userEvent.setup()
const onPreview = vi.fn()
renderPageRow({
isPreviewed: true,
onPreview,
})
expect(screen.getByTestId('notion-page-row-page-1')).toHaveClass('bg-state-base-hover')
await user.click(screen.getByTestId('notion-page-preview-page-1'))
expect(onPreview).toHaveBeenCalledWith('page-1')
})
it('should hide the preview button when showPreview is false', () => {
renderPageRow({ showPreview: false })
expect(screen.queryByTestId('notion-page-preview-page-1')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,127 @@
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { act, renderHook, waitFor } from '@testing-library/react'
import { usePageSelectorModel } from '../use-page-selector-model'
const buildPage = (overrides: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({
page_id: 'page-id',
page_name: 'Page name',
parent_id: 'root',
page_icon: null,
type: 'page',
is_bound: false,
...overrides,
})
const list: DataSourceNotionPage[] = [
buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }),
buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }),
buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }),
buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }),
]
const pagesMap: DataSourceNotionPageMap = {
'root-1': { ...list[0], workspace_id: 'workspace-1' },
'child-1': { ...list[1], workspace_id: 'workspace-1' },
'grandchild-1': { ...list[2], workspace_id: 'workspace-1' },
'child-2': { ...list[3], workspace_id: 'workspace-1' },
}
const createProps = (
overrides: Partial<Parameters<typeof usePageSelectorModel>[0]> = {},
): Parameters<typeof usePageSelectorModel>[0] => ({
checkedIds: new Set<string>(),
searchValue: '',
pagesMap,
list,
onSelect: vi.fn(),
previewPageId: undefined,
onPreview: vi.fn(),
selectionMode: 'multiple',
...overrides,
})
describe('usePageSelectorModel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build visible rows from the expanded tree state', async () => {
const { result } = renderHook(() => usePageSelectorModel(createProps()))
expect(result.current.rows.map(row => row.page.page_id)).toEqual(['root-1'])
act(() => {
result.current.handleToggle('root-1')
})
await waitFor(() => {
expect(result.current.rows.map(row => row.page.page_id)).toEqual([
'root-1',
'child-1',
'child-2',
])
})
act(() => {
result.current.handleToggle('child-1')
})
await waitFor(() => {
expect(result.current.rows.map(row => row.page.page_id)).toEqual([
'root-1',
'child-1',
'grandchild-1',
'child-2',
])
})
})
it('should select descendants when selecting a parent in multiple mode', () => {
const onSelect = vi.fn()
const { result } = renderHook(() => usePageSelectorModel(createProps({ onSelect })))
act(() => {
result.current.handleSelect('root-1')
})
expect(onSelect).toHaveBeenCalledWith(new Set([
'root-1',
'child-1',
'grandchild-1',
'child-2',
]))
})
it('should update local preview and respect the controlled previewPageId when provided', () => {
const onPreview = vi.fn()
const { result, rerender } = renderHook(
props => usePageSelectorModel(props),
{ initialProps: createProps({ onPreview }) },
)
act(() => {
result.current.handlePreview('child-1')
})
expect(onPreview).toHaveBeenCalledWith('child-1')
expect(result.current.currentPreviewPageId).toBe('child-1')
rerender(createProps({ onPreview, previewPageId: 'grandchild-1' }))
expect(result.current.currentPreviewPageId).toBe('grandchild-1')
})
it('should expose filtered rows when the deferred search value changes', async () => {
const { result, rerender } = renderHook(
props => usePageSelectorModel(props),
{ initialProps: createProps() },
)
rerender(createProps({ searchValue: 'Grandchild' }))
await waitFor(() => {
expect(result.current.effectiveSearchValue).toBe('Grandchild')
expect(result.current.rows.map(row => row.page.page_id)).toEqual(['grandchild-1'])
})
})
})

View File

@ -0,0 +1,118 @@
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import {
buildNotionPageTree,
getNextSelectedPageIds,
getRootPageIds,
getVisiblePageRows,
} from '../utils'
const buildPage = (overrides: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({
page_id: 'page-id',
page_name: 'Page name',
parent_id: 'root',
page_icon: null,
type: 'page',
is_bound: false,
...overrides,
})
const list: DataSourceNotionPage[] = [
buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }),
buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }),
buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }),
buildPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-1' }),
buildPage({ page_id: 'orphan-1', page_name: 'Orphan 1', parent_id: 'missing-parent' }),
]
const pagesMap: DataSourceNotionPageMap = {
'root-1': { ...list[0], workspace_id: 'workspace-1' },
'child-1': { ...list[1], workspace_id: 'workspace-1' },
'grandchild-1': { ...list[2], workspace_id: 'workspace-1' },
'child-2': { ...list[3], workspace_id: 'workspace-1' },
'orphan-1': { ...list[4], workspace_id: 'workspace-1' },
}
describe('page-selector utils', () => {
it('should build a tree with descendants, depth, and ancestors', () => {
const treeMap = buildNotionPageTree(list, pagesMap)
expect(treeMap['root-1'].children).toEqual(new Set(['child-1', 'child-2']))
expect(treeMap['root-1'].descendants).toEqual(new Set(['child-1', 'grandchild-1', 'child-2']))
expect(treeMap['grandchild-1'].depth).toBe(2)
expect(treeMap['grandchild-1'].ancestors).toEqual(['Root 1', 'Child 1'])
})
it('should return root page ids for true roots and pages with missing parents', () => {
expect(getRootPageIds(list, pagesMap)).toEqual(['root-1', 'orphan-1'])
})
it('should return expanded tree rows in depth-first order when not searching', () => {
const treeMap = buildNotionPageTree(list, pagesMap)
const rows = getVisiblePageRows({
list,
pagesMap,
searchValue: '',
treeMap,
rootPageIds: ['root-1'],
expandedIds: new Set(['root-1', 'child-1']),
})
expect(rows.map(row => row.page.page_id)).toEqual([
'root-1',
'child-1',
'grandchild-1',
'child-2',
])
})
it('should return filtered search rows with ancestry metadata when searching', () => {
const treeMap = buildNotionPageTree(list, pagesMap)
const rows = getVisiblePageRows({
list,
pagesMap,
searchValue: 'Grandchild',
treeMap,
rootPageIds: ['root-1'],
expandedIds: new Set<string>(),
})
expect(rows).toEqual([
expect.objectContaining({
page: expect.objectContaining({ page_id: 'grandchild-1' }),
ancestors: ['Root 1', 'Child 1'],
hasChild: false,
parentExists: true,
}),
])
})
it('should toggle selected ids correctly in single and multiple mode', () => {
const treeMap = buildNotionPageTree(list, pagesMap)
expect(getNextSelectedPageIds({
checkedIds: new Set(['root-1']),
pageId: 'child-1',
searchValue: '',
selectionMode: 'single',
treeMap,
})).toEqual(new Set(['child-1']))
expect(getNextSelectedPageIds({
checkedIds: new Set<string>(),
pageId: 'root-1',
searchValue: '',
selectionMode: 'multiple',
treeMap,
})).toEqual(new Set(['root-1', 'child-1', 'grandchild-1', 'child-2']))
expect(getNextSelectedPageIds({
checkedIds: new Set(['child-1']),
pageId: 'child-1',
searchValue: 'Child',
selectionMode: 'multiple',
treeMap,
})).toEqual(new Set<string>())
})
})

View File

@ -0,0 +1,144 @@
import type { ComponentProps } from 'react'
import type { NotionPageRow } from '../types'
import { render, screen } from '@testing-library/react'
import VirtualPageList from '../virtual-page-list'
vi.mock('@tanstack/react-virtual')
const pageRowPropsSpy = vi.fn()
type MockPageRowProps = ComponentProps<typeof import('../page-row').default>
vi.mock('../page-row', () => ({
default: ({
checked,
disabled,
isPreviewed,
onPreview,
onSelect,
onToggle,
row,
searchValue,
selectionMode,
showPreview,
style,
}: MockPageRowProps) => {
pageRowPropsSpy({
checked,
disabled,
isPreviewed,
onPreview,
onSelect,
onToggle,
row,
searchValue,
selectionMode,
showPreview,
style,
})
return <div data-testid={`page-row-${row.page.page_id}`} />
},
}))
const buildRow = (overrides: Partial<NotionPageRow> = {}): NotionPageRow => ({
page: {
page_id: 'page-1',
page_name: 'Page 1',
parent_id: 'root',
page_icon: null,
type: 'page',
is_bound: false,
},
parentExists: false,
depth: 0,
expand: false,
hasChild: false,
ancestors: [],
...overrides,
})
describe('VirtualPageList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render virtual rows and pass row state to PageRow', () => {
const rows = [
buildRow(),
buildRow({
page: {
page_id: 'page-2',
page_name: 'Page 2',
parent_id: 'root',
page_icon: null,
type: 'page',
is_bound: false,
},
}),
]
render(
<VirtualPageList
checkedIds={new Set(['page-1'])}
disabledValue={new Set(['page-2'])}
onPreview={vi.fn()}
onSelect={vi.fn()}
onToggle={vi.fn()}
previewPageId="page-2"
rows={rows}
searchValue=""
selectionMode="multiple"
showPreview
/>,
)
expect(screen.getByTestId('virtual-list')).toBeInTheDocument()
expect(screen.getByTestId('page-row-page-1')).toBeInTheDocument()
expect(screen.getByTestId('page-row-page-2')).toBeInTheDocument()
expect(pageRowPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
checked: true,
disabled: false,
isPreviewed: false,
searchValue: '',
selectionMode: 'multiple',
showPreview: true,
row: rows[0],
style: expect.objectContaining({
height: '28px',
width: 'calc(100% - 16px)',
}),
}))
expect(pageRowPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
checked: false,
disabled: true,
isPreviewed: true,
row: rows[1],
}))
})
it('should size the virtual container using the row estimate', () => {
const rows = [buildRow(), buildRow()]
render(
<VirtualPageList
checkedIds={new Set<string>()}
disabledValue={new Set<string>()}
onPreview={vi.fn()}
onSelect={vi.fn()}
onToggle={vi.fn()}
previewPageId=""
rows={rows}
searchValue=""
selectionMode="multiple"
showPreview={false}
/>,
)
const list = screen.getByTestId('virtual-list')
const innerContainer = list.firstElementChild as HTMLElement
expect(innerContainer).toHaveStyle({
height: '56px',
position: 'relative',
})
})
})

View File

@ -0,0 +1,295 @@
import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
import type { LexicalEditor } from 'lexical'
import type { ComponentProps } from 'react'
import type { EventEmitterValue } from '@/context/event-emitter'
import { CodeNode } from '@lexical/code'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
createCommand,
FOCUS_COMMAND,
TextNode,
} from 'lexical'
import { useEffect } from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ContextBlockNode } from '../plugins/context-block'
import { CurrentBlockNode } from '../plugins/current-block'
import { CustomTextNode } from '../plugins/custom-text/node'
import { ErrorMessageBlockNode } from '../plugins/error-message-block'
import { HistoryBlockNode } from '../plugins/history-block'
import { HITLInputNode } from '../plugins/hitl-input-block'
import { LastRunBlockNode } from '../plugins/last-run-block'
import { QueryBlockNode } from '../plugins/query-block'
import { RequestURLBlockNode } from '../plugins/request-url-block'
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '../plugins/update-block'
import { VariableValueBlockNode } from '../plugins/variable-value-block/node'
import { WorkflowVariableBlockNode } from '../plugins/workflow-variable-block'
import PromptEditorContent from '../prompt-editor-content'
import { textToEditorState } from '../utils'
type Captures = {
editor: LexicalEditor | null
eventEmitter: EventEmitter<EventEmitterValue> | null
}
const mockDOMRect = {
x: 100,
y: 100,
width: 100,
height: 20,
top: 100,
right: 200,
bottom: 120,
left: 100,
toJSON: () => ({}),
}
const originalRangeGetClientRects = Range.prototype.getClientRects
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect
const setSelectionOnEditable = (editable: HTMLElement) => {
const lexicalTextNode = editable.querySelector('[data-lexical-text="true"]')?.firstChild
const range = document.createRange()
if (lexicalTextNode) {
range.setStart(lexicalTextNode, 0)
range.setEnd(lexicalTextNode, 1)
}
else {
range.selectNodeContents(editable)
}
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
const CaptureEditorAndEmitter = ({ captures }: { captures: Captures }) => {
const { eventEmitter } = useEventEmitterContextContext()
const [editor] = useLexicalComposerContext()
useEffect(() => {
captures.editor = editor
}, [captures, editor])
useEffect(() => {
captures.eventEmitter = eventEmitter
}, [captures, eventEmitter])
return null
}
const PromptEditorContentHarness = ({
captures,
initialText = '',
...props
}: ComponentProps<typeof PromptEditorContent> & { captures: Captures, initialText?: string }) => (
<EventEmitterContextProvider>
<LexicalComposer
initialConfig={{
namespace: 'prompt-editor-content-test',
editable: true,
nodes: [
CodeNode,
CustomTextNode,
{
replace: TextNode,
with: (node: TextNode) => new CustomTextNode(node.__text),
withKlass: CustomTextNode,
},
ContextBlockNode,
HistoryBlockNode,
QueryBlockNode,
RequestURLBlockNode,
WorkflowVariableBlockNode,
VariableValueBlockNode,
HITLInputNode,
CurrentBlockNode,
ErrorMessageBlockNode,
LastRunBlockNode,
],
editorState: textToEditorState(initialText),
onError: (error: Error) => {
throw error
},
}}
>
<CaptureEditorAndEmitter captures={captures} />
<PromptEditorContent {...props} />
</LexicalComposer>
</EventEmitterContextProvider>
)
describe('PromptEditorContent', () => {
beforeAll(() => {
Range.prototype.getClientRects = vi.fn(() => {
const rectList = [mockDOMRect] as unknown as DOMRectList
Object.defineProperty(rectList, 'length', { value: 1 })
Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
return rectList
})
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
beforeEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
Range.prototype.getClientRects = originalRangeGetClientRects
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect
})
// The extracted content shell should run with the real Lexical stack and forward editor commands through its composed plugins.
describe('Rendering', () => {
it('should render with real dependencies and forward update/focus/blur events', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const onEditorChange = vi.fn()
const onFocus = vi.fn()
const onBlur = vi.fn()
const anchorElem = document.createElement('div')
const { container } = render(
<PromptEditorContentHarness
captures={captures}
compact={true}
className="editor-shell"
placeholder="Type prompt"
shortcutPopups={[]}
instanceId="content-editor"
floatingAnchorElem={anchorElem}
onEditorChange={onEditorChange}
onFocus={onFocus}
onBlur={onBlur}
/>,
)
expect(screen.getByText('Type prompt')).toBeInTheDocument()
const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
expect(editable.className).toContain('text-[13px]')
await waitFor(() => {
expect(captures.editor).not.toBeNull()
expect(captures.eventEmitter).not.toBeNull()
})
act(() => {
captures.eventEmitter?.emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'content-editor',
payload: 'updated prompt',
})
})
await waitFor(() => {
expect(onEditorChange).toHaveBeenCalled()
})
act(() => {
captures.editor?.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
captures.editor?.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: null }))
})
expect(onFocus).toHaveBeenCalledTimes(1)
expect(onBlur).toHaveBeenCalledTimes(1)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render optional blocks and open shortcut popups with the real editor runtime', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const onEditorChange = vi.fn()
const insertCommand = createCommand<string[]>('prompt-editor-shortcut-insert')
const insertSpy = vi.fn()
const Popup = ({ onClose, onInsert }: { onClose: () => void, onInsert: (command: typeof insertCommand, params: string[]) => void }) => (
<>
<button type="button" onClick={() => onInsert(insertCommand, ['from-shortcut'])}>Insert shortcut</button>
<button type="button" onClick={onClose}>Close shortcut</button>
</>
)
const { container } = render(
<PromptEditorContentHarness
captures={captures}
shortcutPopups={[{ hotkey: 'ctrl+/', Popup }]}
initialText="seed prompt"
floatingAnchorElem={document.createElement('div')}
onEditorChange={onEditorChange}
contextBlock={{ show: true, datasets: [] }}
queryBlock={{ show: true }}
requestURLBlock={{ show: true }}
historyBlock={{ show: true, history: { user: 'user-role', assistant: 'assistant-role' } }}
variableBlock={{ show: true, variables: [] }}
externalToolBlock={{ show: true, externalTools: [] }}
workflowVariableBlock={{ show: true, variables: [] }}
hitlInputBlock={{
show: true,
nodeId: 'node-1',
onFormInputItemRemove: vi.fn(),
onFormInputItemRename: vi.fn(),
}}
currentBlock={{ show: true, generatorType: GeneratorType.prompt }}
errorMessageBlock={{ show: true }}
lastRunBlock={{ show: true }}
isSupportFileVar={true}
/>,
)
await waitFor(() => {
expect(captures.editor).not.toBeNull()
})
const unregister = captures.editor?.registerCommand(
insertCommand,
(payload) => {
insertSpy(payload)
return true
},
COMMAND_PRIORITY_EDITOR,
)
const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
editable.focus()
setSelectionOnEditable(editable)
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
const insertButton = await screen.findByRole('button', { name: 'Insert shortcut' })
fireEvent.click(insertButton)
expect(insertSpy).toHaveBeenCalledWith(['from-shortcut'])
expect(onEditorChange).toHaveBeenCalled()
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Insert shortcut' })).not.toBeInTheDocument()
})
unregister?.()
})
it('should keep the shell stable without optional anchor or placeholder overrides', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render(
<PromptEditorContentHarness
captures={captures}
shortcutPopups={[]}
floatingAnchorElem={null}
onEditorChange={vi.fn()}
/>,
)
await waitFor(() => {
expect(captures.editor).not.toBeNull()
})
expect(screen.queryByTestId('draggable-target-line')).not.toBeInTheDocument()
expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument()
})
})
})

View File

@ -22,11 +22,6 @@ import type {
} from './types'
import { CodeNode } from '@lexical/code'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import {
$getRoot,
TextNode,
@ -39,63 +34,37 @@ import {
UPDATE_DATASETS_EVENT_EMITTER,
UPDATE_HISTORY_EVENT_EMITTER,
} from './constants'
import ComponentPickerBlock from './plugins/component-picker-block'
import {
ContextBlock,
ContextBlockNode,
ContextBlockReplacementBlock,
} from './plugins/context-block'
import {
CurrentBlock,
CurrentBlockNode,
CurrentBlockReplacementBlock,
} from './plugins/current-block'
import { CustomTextNode } from './plugins/custom-text/node'
import DraggableBlockPlugin from './plugins/draggable-plugin'
import {
ErrorMessageBlock,
ErrorMessageBlockNode,
ErrorMessageBlockReplacementBlock,
} from './plugins/error-message-block'
import {
HistoryBlock,
HistoryBlockNode,
HistoryBlockReplacementBlock,
} from './plugins/history-block'
import {
HITLInputBlock,
HITLInputBlockReplacementBlock,
HITLInputNode,
} from './plugins/hitl-input-block'
import {
LastRunBlock,
LastRunBlockNode,
LastRunReplacementBlock,
} from './plugins/last-run-block'
import OnBlurBlock from './plugins/on-blur-or-focus-block'
// import TreeView from './plugins/tree-view'
import Placeholder from './plugins/placeholder'
import {
QueryBlock,
QueryBlockNode,
QueryBlockReplacementBlock,
} from './plugins/query-block'
import {
RequestURLBlock,
RequestURLBlockNode,
RequestURLBlockReplacementBlock,
} from './plugins/request-url-block'
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
import UpdateBlock from './plugins/update-block'
import VariableBlock from './plugins/variable-block'
import VariableValueBlock from './plugins/variable-value-block'
import { VariableValueBlockNode } from './plugins/variable-value-block/node'
import {
WorkflowVariableBlock,
WorkflowVariableBlockNode,
WorkflowVariableBlockReplacementBlock,
} from './plugins/workflow-variable-block'
import PromptEditorContent from './prompt-editor-content'
import { textToEditorState } from './utils'
export type PromptEditorProps = {
@ -214,152 +183,31 @@ const PromptEditor: FC<PromptEditorProps> = ({
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className={cn('relative', wrapperClassName)} ref={onRef}>
<RichTextPlugin
contentEditable={(
<ContentEditable
className={cn(
'group/editable text-text-secondary outline-hidden group-[.clamp]:max-h-24 group-[.clamp]:overflow-y-auto',
compact ? 'text-[13px] leading-5' : 'text-sm leading-6',
className,
)}
style={style || {}}
/>
)}
placeholder={(
<Placeholder
value={placeholder}
className={cn('truncate', placeholderClassName)}
compact={compact}
/>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
{shortcutPopups?.map(({ hotkey, Popup }, idx) => (
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
</ShortcutsPopupPlugin>
))}
<ComponentPickerBlock
triggerString="/"
<PromptEditorContent
compact={compact}
className={className}
placeholder={placeholder}
placeholderClassName={placeholderClassName}
style={style}
shortcutPopups={shortcutPopups}
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
historyBlock={historyBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
hitlInputBlock={hitlInputBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
onBlur={onBlur}
onFocus={onFocus}
instanceId={instanceId}
floatingAnchorElem={floatingAnchorElem}
onEditorChange={handleEditorChange}
/>
<ComponentPickerBlock
triggerString="{"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
/>
{
contextBlock?.show && (
<>
<ContextBlock {...contextBlock} />
<ContextBlockReplacementBlock {...contextBlock} />
</>
)
}
{
queryBlock?.show && (
<>
<QueryBlock {...queryBlock} />
<QueryBlockReplacementBlock />
</>
)
}
{
historyBlock?.show && (
<>
<HistoryBlock {...historyBlock} />
<HistoryBlockReplacementBlock {...historyBlock} />
</>
)
}
{
(variableBlock?.show || externalToolBlock?.show) && (
<>
<VariableBlock />
<VariableValueBlock />
</>
)
}
{
workflowVariableBlock?.show && (
<>
<WorkflowVariableBlock {...workflowVariableBlock} />
<WorkflowVariableBlockReplacementBlock {...workflowVariableBlock} />
</>
)
}
{
hitlInputBlock?.show && (
<>
<HITLInputBlock {...hitlInputBlock} />
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
</>
)
}
{
currentBlock?.show && (
<>
<CurrentBlock {...currentBlock} />
<CurrentBlockReplacementBlock {...currentBlock} />
</>
)
}
{
requestURLBlock?.show && (
<>
<RequestURLBlock {...requestURLBlock} />
<RequestURLBlockReplacementBlock {...requestURLBlock} />
</>
)
}
{
errorMessageBlock?.show && (
<>
<ErrorMessageBlock {...errorMessageBlock} />
<ErrorMessageBlockReplacementBlock {...errorMessageBlock} />
</>
)
}
{
lastRunBlock?.show && (
<>
<LastRunBlock {...lastRunBlock} />
<LastRunReplacementBlock {...lastRunBlock} />
</>
)
}
{
isSupportFileVar && (
<VariableValueBlock />
)
}
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{floatingAnchorElem && (
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
)}
{/* <TreeView /> */}
</div>
</LexicalComposer>
)

View File

@ -0,0 +1,257 @@
import type {
EditorState,
LexicalCommand,
} from 'lexical'
import type { FC } from 'react'
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
ExternalToolBlockType,
HistoryBlockType,
HITLInputBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from './types'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import ComponentPickerBlock from './plugins/component-picker-block'
import {
ContextBlock,
ContextBlockReplacementBlock,
} from './plugins/context-block'
import {
CurrentBlock,
CurrentBlockReplacementBlock,
} from './plugins/current-block'
import DraggableBlockPlugin from './plugins/draggable-plugin'
import {
ErrorMessageBlock,
ErrorMessageBlockReplacementBlock,
} from './plugins/error-message-block'
import {
HistoryBlock,
HistoryBlockReplacementBlock,
} from './plugins/history-block'
import {
HITLInputBlock,
HITLInputBlockReplacementBlock,
} from './plugins/hitl-input-block'
import {
LastRunBlock,
LastRunReplacementBlock,
} from './plugins/last-run-block'
import OnBlurBlock from './plugins/on-blur-or-focus-block'
import Placeholder from './plugins/placeholder'
import {
QueryBlock,
QueryBlockReplacementBlock,
} from './plugins/query-block'
import {
RequestURLBlock,
RequestURLBlockReplacementBlock,
} from './plugins/request-url-block'
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
import UpdateBlock from './plugins/update-block'
import VariableBlock from './plugins/variable-block'
import VariableValueBlock from './plugins/variable-value-block'
import {
WorkflowVariableBlock,
WorkflowVariableBlockReplacementBlock,
} from './plugins/workflow-variable-block'
type ShortcutPopup = {
hotkey: Hotkey
Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void }>
}
type PromptEditorContentProps = {
compact?: boolean
className?: string
placeholder?: string | React.ReactNode
placeholderClassName?: string
style?: React.CSSProperties
shortcutPopups: ShortcutPopup[]
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
hitlInputBlock?: HITLInputBlockType
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
onBlur?: () => void
onFocus?: () => void
instanceId?: string
floatingAnchorElem: HTMLDivElement | null
onEditorChange: (editorState: EditorState) => void
}
const PromptEditorContent: FC<PromptEditorContentProps> = ({
compact,
className,
placeholder,
placeholderClassName,
style,
shortcutPopups,
contextBlock,
queryBlock,
requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
hitlInputBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
isSupportFileVar,
onBlur,
onFocus,
instanceId,
floatingAnchorElem,
onEditorChange,
}) => {
return (
<>
<RichTextPlugin
contentEditable={(
<ContentEditable
className={cn(
'group/editable text-text-secondary outline-hidden group-[.clamp]:max-h-24 group-[.clamp]:overflow-y-auto',
compact ? 'text-[13px] leading-5' : 'text-sm leading-6',
className,
)}
style={style || {}}
/>
)}
placeholder={(
<Placeholder
value={placeholder}
className={cn('truncate', placeholderClassName)}
compact={compact}
/>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
{shortcutPopups.map(({ hotkey, Popup }, idx) => (
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
</ShortcutsPopupPlugin>
))}
<ComponentPickerBlock
triggerString="/"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
/>
<ComponentPickerBlock
triggerString="{"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
/>
{contextBlock?.show && (
<>
<ContextBlock {...contextBlock} />
<ContextBlockReplacementBlock {...contextBlock} />
</>
)}
{queryBlock?.show && (
<>
<QueryBlock {...queryBlock} />
<QueryBlockReplacementBlock />
</>
)}
{historyBlock?.show && (
<>
<HistoryBlock {...historyBlock} />
<HistoryBlockReplacementBlock {...historyBlock} />
</>
)}
{(variableBlock?.show || externalToolBlock?.show) && (
<>
<VariableBlock />
<VariableValueBlock />
</>
)}
{workflowVariableBlock?.show && (
<>
<WorkflowVariableBlock {...workflowVariableBlock} />
<WorkflowVariableBlockReplacementBlock {...workflowVariableBlock} />
</>
)}
{hitlInputBlock?.show && (
<>
<HITLInputBlock {...hitlInputBlock} />
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
</>
)}
{currentBlock?.show && (
<>
<CurrentBlock {...currentBlock} />
<CurrentBlockReplacementBlock {...currentBlock} />
</>
)}
{requestURLBlock?.show && (
<>
<RequestURLBlock {...requestURLBlock} />
<RequestURLBlockReplacementBlock {...requestURLBlock} />
</>
)}
{errorMessageBlock?.show && (
<>
<ErrorMessageBlock {...errorMessageBlock} />
<ErrorMessageBlockReplacementBlock {...errorMessageBlock} />
</>
)}
{lastRunBlock?.show && (
<>
<LastRunBlock {...lastRunBlock} />
<LastRunReplacementBlock {...lastRunBlock} />
</>
)}
{isSupportFileVar && (
<VariableValueBlock />
)}
<OnChangePlugin onChange={onEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{floatingAnchorElem && (
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
)}
</>
)
}
export default PromptEditorContent

View File

@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import Loading from '../loading'
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children, className }: { children?: ReactNode, className?: string }) => (
<div data-testid="skeleton-container" className={className}>{children}</div>
),
SkeletonRectangle: ({ className }: { className?: string }) => (
<div data-testid="skeleton-rectangle" className={className} />
),
}))
describe('CreateFromPipelinePreviewLoading', () => {
it('should render the preview loading shell and all skeleton blocks', () => {
const { container } = render(<Loading />)
expect(container.firstElementChild).toHaveClass(
'flex',
'h-full',
'w-full',
'flex-col',
'overflow-hidden',
'px-6',
'py-5',
)
expect(screen.getAllByTestId('skeleton-container')).toHaveLength(6)
expect(screen.getAllByTestId('skeleton-rectangle')).toHaveLength(29)
})
})

View File

@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { renderHook } from '@testing-library/react'
import { DocumentContext, useDocumentContext } from '../context'
describe('DocumentContext', () => {
it('should return the default empty context value when no provider is present', () => {
const { result } = renderHook(() => useDocumentContext(value => value))
expect(result.current).toEqual({})
})
it('should select values from the nearest provider', () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<DocumentContext.Provider value={{
datasetId: 'dataset-1',
documentId: 'document-1',
}}
>
{children}
</DocumentContext.Provider>
)
const { result } = renderHook(
() => useDocumentContext(value => `${value.datasetId}:${value.documentId}`),
{ wrapper },
)
expect(result.current).toBe('dataset-1:document-1')
})
})

View File

@ -0,0 +1,55 @@
import type { ReactNode } from 'react'
import { renderHook } from '@testing-library/react'
import { SegmentListContext, useSegmentListContext } from '../segment-list-context'
describe('SegmentListContext', () => {
it('should expose the default collapsed state', () => {
const { result } = renderHook(() => useSegmentListContext(value => value))
expect(result.current).toEqual({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: expect.any(Function),
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
})
it('should select provider values from the current segment list context', () => {
const toggleFullScreen = vi.fn()
const wrapper = ({ children }: { children: ReactNode }) => (
<SegmentListContext.Provider value={{
isCollapsed: false,
fullScreen: true,
toggleFullScreen,
currSegment: {
showModal: true,
isEditMode: true,
segInfo: { id: 'segment-1' } as never,
},
currChildChunk: {
showModal: true,
childChunkInfo: { id: 'child-1' } as never,
},
}}
>
{children}
</SegmentListContext.Provider>
)
const { result } = renderHook(
() => useSegmentListContext(value => ({
fullScreen: value.fullScreen,
segmentOpen: value.currSegment.showModal,
childOpen: value.currChildChunk.showModal,
})),
{ wrapper },
)
expect(result.current).toEqual({
fullScreen: true,
segmentOpen: true,
childOpen: true,
})
})
})

View File

@ -0,0 +1,125 @@
import type { TocItem } from '../use-doc-toc'
import { act, renderHook } from '@testing-library/react'
import { useDocToc } from '../use-doc-toc'
const mockMatchMedia = (matches: boolean) => {
vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})))
}
const setupDocument = () => {
document.body.innerHTML = `
<div class="overflow-auto"></div>
<article>
<h2 id="intro"><a href="#intro">Intro</a></h2>
<h2 id="details"><a href="#details">Details</a></h2>
</article>
`
const scrollContainer = document.querySelector('.overflow-auto') as HTMLDivElement
scrollContainer.scrollTo = vi.fn()
const intro = document.getElementById('intro') as HTMLElement
const details = document.getElementById('details') as HTMLElement
Object.defineProperty(intro, 'offsetTop', { configurable: true, value: 140 })
Object.defineProperty(details, 'offsetTop', { configurable: true, value: 320 })
return {
scrollContainer,
intro,
details,
}
}
describe('useDocToc', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
document.body.innerHTML = ''
mockMatchMedia(false)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('should extract headings and expand the TOC on wide screens', async () => {
setupDocument()
mockMatchMedia(true)
const { result } = renderHook(() => useDocToc({
appDetail: { id: 'app-1' },
locale: 'en',
}))
act(() => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual<TocItem[]>([
{ href: '#intro', text: 'Intro' },
{ href: '#details', text: 'Details' },
])
expect(result.current.activeSection).toBe('intro')
expect(result.current.isTocExpanded).toBe(true)
})
it('should update the active section when the scroll container scrolls', async () => {
const { scrollContainer, intro, details } = setupDocument()
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
intro.getBoundingClientRect = vi.fn(() => ({ top: 500 } as DOMRect))
details.getBoundingClientRect = vi.fn(() => ({ top: 300 } as DOMRect))
const { result } = renderHook(() => useDocToc({
appDetail: { id: 'app-1' },
locale: 'en',
}))
act(() => {
vi.runAllTimers()
})
act(() => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('details')
})
it('should scroll the container to the clicked heading offset', async () => {
const { scrollContainer } = setupDocument()
const { result } = renderHook(() => useDocToc({
appDetail: { id: 'app-1' },
locale: 'en',
}))
act(() => {
vi.runAllTimers()
})
const preventDefault = vi.fn()
act(() => {
result.current.handleTocClick(
{ preventDefault } as unknown as React.MouseEvent<HTMLAnchorElement>,
{ href: '#details', text: 'Details' },
)
})
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(scrollContainer.scrollTo).toHaveBeenCalledWith({
top: 240,
behavior: 'smooth',
})
})
})

View File

@ -0,0 +1,69 @@
import type { SearchResult } from '../types'
import { ragPipelineNodesAction } from '../rag-pipeline-nodes'
import { workflowNodesAction } from '../workflow-nodes'
describe('workflowNodesAction', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowNodesAction.searchFn = undefined
})
it('should return an empty result when no workflow search function is registered', async () => {
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([])
})
it('should delegate to the injected workflow search function', async () => {
const results: SearchResult[] = [
{ id: 'workflow-node-1', title: 'LLM', type: 'workflow-node', data: {} as never },
]
workflowNodesAction.searchFn = vi.fn().mockReturnValue(results)
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual(results)
expect(workflowNodesAction.searchFn).toHaveBeenCalledWith('llm')
})
it('should warn and return an empty list when workflow node search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowNodesAction.searchFn = vi.fn(() => {
throw new Error('failed')
})
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Workflow nodes search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})
describe('ragPipelineNodesAction', () => {
beforeEach(() => {
vi.clearAllMocks()
ragPipelineNodesAction.searchFn = undefined
})
it('should return an empty result when no rag pipeline search function is registered', async () => {
await expect(ragPipelineNodesAction.search('@node embed', 'embed', 'en')).resolves.toEqual([])
})
it('should delegate to the injected rag pipeline search function', async () => {
const results: SearchResult[] = [
{ id: 'rag-node-1', title: 'Retriever', type: 'workflow-node', data: {} as never },
]
ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results)
await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual(results)
expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve')
})
it('should warn and return an empty list when rag pipeline node search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
ragPipelineNodesAction.searchFn = vi.fn(() => {
throw new Error('failed')
})
await expect(ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en')).resolves.toEqual([])
expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error))
warnSpy.mockRestore()
})
})

View File

@ -0,0 +1,124 @@
import type { SearchResult } from '../../types'
import { render } from '@testing-library/react'
import { slashAction, SlashCommandProvider } from '../slash'
const {
mockSetTheme,
mockSetLocale,
mockExecuteCommand,
mockRegister,
mockSearch,
mockUnregister,
} = vi.hoisted(() => ({
mockSetTheme: vi.fn(),
mockSetLocale: vi.fn(),
mockExecuteCommand: vi.fn(),
mockRegister: vi.fn(),
mockSearch: vi.fn(),
mockUnregister: vi.fn(),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({
setTheme: mockSetTheme,
}),
}))
vi.mock('react-i18next', () => ({
getI18n: () => ({
language: 'ja',
t: (key: string) => key,
}),
}))
vi.mock('@/i18n-config', () => ({
setLocaleOnClient: mockSetLocale,
}))
vi.mock('../command-bus', () => ({
executeCommand: (...args: unknown[]) => mockExecuteCommand(...args),
}))
vi.mock('../registry', () => ({
slashCommandRegistry: {
register: (...args: unknown[]) => mockRegister(...args),
search: (...args: unknown[]) => mockSearch(...args),
unregister: (...args: unknown[]) => mockUnregister(...args),
},
}))
describe('slashAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose translated title and description', () => {
expect(slashAction.title).toBe('gotoAnything.actions.slashTitle')
expect(slashAction.description).toBe('gotoAnything.actions.slashDesc')
})
it('should execute command results and ignore non-command results', () => {
slashAction.action?.({
id: 'cmd-1',
title: 'Command',
type: 'command',
data: {
command: 'navigation.docs',
args: { path: '/docs' },
},
} as SearchResult)
slashAction.action?.({
id: 'app-1',
title: 'App',
type: 'app',
data: {} as never,
} as SearchResult)
expect(mockExecuteCommand).toHaveBeenCalledTimes(1)
expect(mockExecuteCommand).toHaveBeenCalledWith('navigation.docs', { path: '/docs' })
})
it('should delegate search to the slash command registry with the active language', async () => {
mockSearch.mockResolvedValue([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }])
const results = await slashAction.search('/theme dark', 'dark')
expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja')
expect(results).toEqual([{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }])
})
})
describe('SlashCommandProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should register commands on mount and unregister them on unmount', () => {
const { unmount } = render(<SlashCommandProvider />)
expect(mockRegister.mock.calls.map(call => call[0].name)).toEqual([
'theme',
'language',
'forum',
'docs',
'community',
'account',
'zen',
])
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
unmount()
expect(mockUnregister.mock.calls.map(call => call[0])).toEqual([
'theme',
'language',
'forum',
'docs',
'community',
'account',
'zen',
])
})
})

View File

@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react'
import { ExternalLinkIndicator, MenuItemContent } from '../menu-item-content'
describe('MenuItemContent', () => {
it('should render the icon, label, and trailing content', () => {
const { container } = render(
<MenuItemContent
iconClassName="i-ri-settings-4-line"
label="Settings"
trailing={<span data-testid="menu-trailing">Soon</span>}
/>,
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByTestId('menu-trailing')).toHaveTextContent('Soon')
expect(container.querySelector('.i-ri-settings-4-line')).toBeInTheDocument()
})
})
describe('ExternalLinkIndicator', () => {
it('should render the external-link icon with aria-hidden semantics', () => {
const { container } = render(<ExternalLinkIndicator />)
const indicator = container.querySelector('.i-ri-arrow-right-up-line')
expect(indicator).toBeInTheDocument()
expect(indicator).toHaveAttribute('aria-hidden')
})
})

View File

@ -0,0 +1,23 @@
import * as ModelAuth from '../index'
vi.mock('../add-credential-in-load-balancing', () => ({ default: 'AddCredentialInLoadBalancing' }))
vi.mock('../add-custom-model', () => ({ default: 'AddCustomModel' }))
vi.mock('../authorized', () => ({ default: 'Authorized' }))
vi.mock('../config-model', () => ({ default: 'ConfigModel' }))
vi.mock('../credential-selector', () => ({ default: 'CredentialSelector' }))
vi.mock('../manage-custom-model-credentials', () => ({ default: 'ManageCustomModelCredentials' }))
vi.mock('../switch-credential-in-load-balancing', () => ({ default: 'SwitchCredentialInLoadBalancing' }))
describe('model-auth index exports', () => {
it('should re-export the model auth entry points', () => {
expect(ModelAuth).toMatchObject({
AddCredentialInLoadBalancing: 'AddCredentialInLoadBalancing',
AddCustomModel: 'AddCustomModel',
Authorized: 'Authorized',
ConfigModel: 'ConfigModel',
CredentialSelector: 'CredentialSelector',
ManageCustomModelCredentials: 'ManageCustomModelCredentials',
SwitchCredentialInLoadBalancing: 'SwitchCredentialInLoadBalancing',
})
})
})

View File

@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import CreditsFallbackAlert from '../credits-fallback-alert'
describe('CreditsFallbackAlert', () => {
it('should render the credential fallback copy and description when credentials exist', () => {
render(<CreditsFallbackAlert hasCredentials />)
expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallback')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).toBeInTheDocument()
})
it('should render the no-credentials fallback copy without the description', () => {
render(<CreditsFallbackAlert hasCredentials={false} />)
expect(screen.getByText('common.modelProvider.card.noApiKeysFallback')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.card.apiKeyUnavailableFallbackDescription')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,16 @@
import { render } from '@testing-library/react'
import DownloadingIcon from '../downloading-icon'
describe('DownloadingIcon', () => {
it('should render the animated install icon wrapper and svg markup', () => {
const { container } = render(<DownloadingIcon />)
const wrapper = container.firstElementChild as HTMLElement
const svg = container.querySelector('svg.install-icon')
expect(wrapper).toHaveClass('inline-flex', 'text-components-button-secondary-text')
expect(svg).toBeInTheDocument()
expect(svg).toHaveAttribute('viewBox', '0 0 24 24')
expect(svg?.querySelectorAll('path')).toHaveLength(3)
})
})

View File

@ -0,0 +1,219 @@
import type { TextGenerationRunControl } from '../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import TextGeneration from '../index'
const {
mockMode,
mockMedia,
mockAppStateRef,
mockBatchStateRef,
sidebarPropsSpy,
resultPanelPropsSpy,
mockSetIsCallBatchAPI,
mockResetBatchExecution,
mockHandleRunBatch,
} = vi.hoisted(() => ({
mockMode: { value: 'create' },
mockMedia: { value: 'pc' },
mockAppStateRef: { value: null as unknown },
mockBatchStateRef: { value: null as unknown },
sidebarPropsSpy: vi.fn(),
resultPanelPropsSpy: vi.fn(),
mockSetIsCallBatchAPI: vi.fn(),
mockResetBatchExecution: vi.fn(),
mockHandleRunBatch: vi.fn(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
MediaType: {
mobile: 'mobile',
pc: 'pc',
tablet: 'tablet',
},
default: () => mockMedia.value,
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => key === 'mode' ? mockMode.value : null,
}),
}))
vi.mock('@/app/components/base/loading', () => ({
default: ({ type }: { type: string }) => <div data-testid="loading-app">{type}</div>,
}))
vi.mock('../hooks/use-text-generation-app-state', () => ({
useTextGenerationAppState: () => mockAppStateRef.value,
}))
vi.mock('../hooks/use-text-generation-batch', () => ({
useTextGenerationBatch: () => mockBatchStateRef.value,
}))
vi.mock('../text-generation-sidebar', () => ({
default: (props: {
currentTab: string
onRunOnceSend: () => void
onBatchSend: (data: string[][]) => void
}) => {
sidebarPropsSpy(props)
return (
<div data-testid="sidebar">
<span data-testid="sidebar-current-tab">{props.currentTab}</span>
<button type="button" onClick={props.onRunOnceSend}>run-once</button>
<button type="button" onClick={() => props.onBatchSend([['name'], ['Alice']])}>run-batch</button>
</div>
)
},
}))
vi.mock('../text-generation-result-panel', () => ({
default: (props: {
allTaskList: unknown[]
controlSend: number
controlStopResponding: number
isShowResultPanel: boolean
onRunControlChange: (value: TextGenerationRunControl | null) => void
onRunStart: () => void
}) => {
resultPanelPropsSpy(props)
return (
<div data-testid="result-panel">
<span data-testid="show-result">{props.isShowResultPanel ? 'shown' : 'hidden'}</span>
<span data-testid="control-send">{String(props.controlSend)}</span>
<span data-testid="control-stop">{String(props.controlStopResponding)}</span>
<span data-testid="task-count">{String(props.allTaskList.length)}</span>
<button
type="button"
onClick={() => props.onRunControlChange({ isStopping: false, onStop: vi.fn() })}
>
set-run-control
</button>
<button type="button" onClick={props.onRunStart}>start-run</button>
</div>
)
},
}))
const createAppState = (overrides: Record<string, unknown> = {}) => ({
accessMode: AccessMode.PUBLIC,
appId: 'app-1',
appSourceType: 'webApp',
customConfig: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
handleRemoveSavedMessage: vi.fn(),
handleSaveMessage: vi.fn(),
moreLikeThisConfig: { enabled: true },
promptConfig: {
prompt_template: '',
prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }],
},
savedMessages: [],
siteInfo: {
title: 'Generator',
description: 'Description',
},
systemFeatures: {},
textToSpeechConfig: { enabled: true },
visionConfig: { enabled: false },
...overrides,
})
const createBatchState = (overrides: Record<string, unknown> = {}) => ({
allFailedTaskList: [],
allSuccessTaskList: [],
allTaskList: [],
allTasksRun: true,
controlRetry: 0,
exportRes: [],
handleCompleted: vi.fn(),
handleRetryAllFailedTask: vi.fn(),
handleRunBatch: (data: string[][], options: { onStart: () => void }) => {
mockHandleRunBatch(data, options)
options.onStart()
return true
},
isCallBatchAPI: false,
noPendingTask: true,
resetBatchExecution: () => mockResetBatchExecution(),
setIsCallBatchAPI: (value: boolean) => mockSetIsCallBatchAPI(value),
showTaskList: [],
...overrides,
})
describe('TextGeneration', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockMode.value = 'create'
mockMedia.value = 'pc'
mockAppStateRef.value = createAppState()
mockBatchStateRef.value = createBatchState()
})
afterEach(() => {
vi.useRealTimers()
})
it('should render the loading state until app state is ready', () => {
mockAppStateRef.value = createAppState({ appId: '', siteInfo: null, promptConfig: null })
render(<TextGeneration />)
expect(screen.getByTestId('loading-app')).toHaveTextContent('app')
})
it('should fall back to create mode for unsupported query params and keep installed-app layout classes', () => {
mockMode.value = 'unsupported'
const { container } = render(<TextGeneration isInstalledApp />)
expect(screen.getByTestId('sidebar-current-tab')).toHaveTextContent('create')
expect(sidebarPropsSpy).toHaveBeenCalledWith(expect.objectContaining({
currentTab: 'create',
isInstalledApp: true,
isPC: true,
}))
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass('flex', 'h-full', 'rounded-2xl', 'shadow-md')
})
it('should orchestrate a run-once request and reveal the result panel', async () => {
render(<TextGeneration />)
fireEvent.click(screen.getByRole('button', { name: 'run-once' }))
act(() => {
vi.runAllTimers()
})
expect(mockSetIsCallBatchAPI).toHaveBeenCalledWith(false)
expect(mockResetBatchExecution).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('show-result')).toHaveTextContent('shown')
expect(Number(screen.getByTestId('control-send').textContent)).toBeGreaterThan(0)
})
it('should orchestrate batch runs through the batch hook and expose the result panel', async () => {
mockMode.value = 'batch'
render(<TextGeneration />)
fireEvent.click(screen.getByRole('button', { name: 'run-batch' }))
act(() => {
vi.runAllTimers()
})
expect(mockHandleRunBatch).toHaveBeenCalledWith(
[['name'], ['Alice']],
expect.objectContaining({ onStart: expect.any(Function) }),
)
expect(screen.getByTestId('show-result')).toHaveTextContent('shown')
expect(Number(screen.getByTestId('control-stop').textContent)).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,41 @@
import { renderHook } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import { useIsChatMode } from '../use-is-chat-mode'
const { mockStoreState } = vi.hoisted(() => ({
mockStoreState: {
appDetail: undefined as { mode?: AppModeEnum } | undefined,
},
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState),
}))
describe('useIsChatMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStoreState.appDetail = undefined
})
it('should return true when the app mode is ADVANCED_CHAT', () => {
mockStoreState.appDetail = { mode: AppModeEnum.ADVANCED_CHAT }
const { result } = renderHook(() => useIsChatMode())
expect(result.current).toBe(true)
})
it('should return false when the app mode is not chat or app detail is missing', () => {
mockStoreState.appDetail = { mode: AppModeEnum.WORKFLOW }
const { result, rerender } = renderHook(() => useIsChatMode())
expect(result.current).toBe(false)
mockStoreState.appDetail = undefined
rerender()
expect(result.current).toBe(false)
})
})

View File

@ -229,6 +229,23 @@ describe('output-schema-utils', () => {
})
})
describe('Dify compact types (workflow-as-tool output_schema)', () => {
it('should resolve array[string] to arrayString (issue #34428)', () => {
const result = resolveVarType({ type: 'array[string]' })
expect(result.type).toBe(VarType.arrayString)
})
it('should resolve Array[string] case-insensitively', () => {
const result = resolveVarType({ type: 'Array[string]' })
expect(result.type).toBe(VarType.arrayString)
})
it('should resolve array[object] to arrayObject', () => {
const result = resolveVarType({ type: 'array[object]' })
expect(result.type).toBe(VarType.arrayObject)
})
})
describe('unknown types', () => {
it('should resolve unknown type to any', () => {
const result = resolveVarType({ type: 'unknown_type' })

View File

@ -2,6 +2,30 @@ import type { SchemaTypeDefinition } from '@/service/use-common'
import { VarType } from '@/app/components/workflow/types'
import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type'
/**
* Workflow-as-tool and some internal APIs store Dify VarType strings (e.g. `array[string]`)
* in JSON Schema `type` instead of standard `{ type: 'array', items: { type: 'string' } }`.
* Map those compact strings to VarType so downstream (e.g. Code node var picker) does not
* fall back to `any` and get filtered out.
*/
const resolveDifyCompactTypeString = (typeStr: string): VarType | undefined => {
const trimmed = typeStr.trim()
const m = /^array\[(string|number|integer|boolean|object|file|any)\]$/i.exec(trimmed)
if (!m)
return undefined
const inner = m[1].toLowerCase()
const map: Record<string, VarType> = {
string: VarType.arrayString,
number: VarType.arrayNumber,
integer: VarType.arrayNumber,
boolean: VarType.arrayBoolean,
object: VarType.arrayObject,
file: VarType.arrayFile,
any: VarType.arrayAny,
}
return map[inner]
}
/**
* Normalizes a JSON Schema type to a simple string type.
* Handles complex schemas with oneOf, anyOf, allOf.
@ -54,6 +78,12 @@ export const resolveVarType = (
schemaTypeDefinitions?: SchemaTypeDefinition[],
): { type: VarType, schemaType?: string } => {
const schemaType = getMatchedSchemaType(schema, schemaTypeDefinitions)
if (schema && typeof schema.type === 'string') {
const compact = resolveDifyCompactTypeString(schema.type)
if (compact !== undefined)
return { type: compact, schemaType }
}
const normalizedType = normalizeJsonSchemaType(schema)
switch (normalizedType) {

View File

@ -1885,12 +1885,6 @@
}
},
"app/components/base/chat/chat/index.tsx": {
"react/set-state-in-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 3
}

View File

@ -61,13 +61,15 @@ const createResponseFromHTTPError = (error: HTTPError): Response => {
const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => {
return async ({ response }) => {
if (!/^([23])\d{2}$/.test(String(response.status))) {
const errorData = await response.clone()
.json()
.then(data => data as ResponseError)
.catch(() => null)
let errorData: ResponseError | null = null
try {
const data: unknown = await response.clone().json()
errorData = data as ResponseError
}
catch {}
const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent
if (shouldNotifyError)
if (shouldNotifyError && errorData)
toast.error(errorData.message)
if (response.status === 403 && errorData?.code === 'already_setup')