Compare commits

...

3 Commits

Author SHA1 Message Date
-LAN-
59639ca9b2 chore: bump Dify to 1.13.3 and sandbox to 0.2.13 (#34079)
Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-25 20:03:15 +08:00
Xin Zhang
66b8c42a25 feat: add inner API endpoints for admin DSL import/export (#34059) 2026-03-25 19:48:53 +08:00
Coding On Star
449d8c7768 test(workflow-app): enhance unit tests for workflow components and hooks (#34065)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com>
Co-authored-by: Desel72 <pedroluiscolmenares722@gmail.com>
Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com>
Co-authored-by: Krishna Chaitanya <krishnabkc15@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 18:34:32 +08:00
54 changed files with 5990 additions and 820 deletions

View File

@@ -16,12 +16,14 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from .app import dsl as _app_dsl
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
__all__ = [
"_app_dsl",
"_mail",
"_plugin",
"_workspace",

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,110 @@
"""Inner API endpoints for app DSL import/export.
Called by the enterprise admin-api service. Import requires ``creator_email``
to attribute the created app; workspace/membership validation is done by the
Go admin-api caller.
"""
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from extensions.ext_database import db
from models import Account, App
from models.account import AccountStatus
from services.app_dsl_service import AppDslService, ImportMode, ImportStatus
class InnerAppDSLImportPayload(BaseModel):
yaml_content: str = Field(description="YAML DSL content")
creator_email: str = Field(description="Email of the workspace member who will own the imported app")
name: str | None = Field(default=None, description="Override app name from DSL")
description: str | None = Field(default=None, description="Override app description from DSL")
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
@inner_api_ns.route("/enterprise/workspaces/<string:workspace_id>/dsl/import")
class EnterpriseAppDSLImport(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc("enterprise_app_dsl_import")
@inner_api_ns.expect(inner_api_ns.models[InnerAppDSLImportPayload.__name__])
@inner_api_ns.doc(
responses={
200: "Import completed",
202: "Import pending (DSL version mismatch requires confirmation)",
400: "Import failed (business error)",
404: "Creator account not found or inactive",
}
)
def post(self, workspace_id: str):
"""Import a DSL into a workspace on behalf of a specified creator."""
args = InnerAppDSLImportPayload.model_validate(inner_api_ns.payload or {})
account = _get_active_account(args.creator_email)
if account is None:
return {"message": f"account '{args.creator_email}' not found or inactive"}, 404
account.set_tenant_id(workspace_id)
with Session(db.engine) as session:
dsl_service = AppDslService(session)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
yaml_content=args.yaml_content,
name=args.name,
description=args.description,
)
session.commit()
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
if result.status == ImportStatus.PENDING:
return result.model_dump(mode="json"), 202
return result.model_dump(mode="json"), 200
@inner_api_ns.route("/enterprise/apps/<string:app_id>/dsl")
class EnterpriseAppDSLExport(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_app_dsl_export",
responses={
200: "Export successful",
404: "App not found",
},
)
def get(self, app_id: str):
"""Export an app's DSL as YAML."""
include_secret = request.args.get("include_secret", "false").lower() == "true"
app_model = db.session.query(App).filter_by(id=app_id).first()
if not app_model:
return {"message": "app not found"}, 404
data = AppDslService.export_dsl(
app_model=app_model,
include_secret=include_secret,
)
return {"data": data}, 200
def _get_active_account(email: str) -> Account | None:
"""Look up an active account by email.
Workspace membership is already validated by the Go admin-api caller.
"""
account = db.session.query(Account).filter_by(email=email).first()
if account is None or account.status != AccountStatus.ACTIVE:
return None
return account

View File

@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.13.2"
version = "1.13.3"
requires-python = ">=3.11,<3.13"
dependencies = [

View File

@@ -163,11 +163,9 @@ class DifyTestContainers:
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
logger.info("Redis container is ready and accepting connections")
# Start Dify Sandbox container for code execution environment
# Dify Sandbox provides a secure environment for executing user code
# Use pinned version 0.2.12 to match production docker-compose configuration
# Start Dify Sandbox container for code execution environment.
logger.info("Initializing Dify Sandbox container...")
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.12").with_network(self.network)
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.14").with_network(self.network)
self.dify_sandbox.with_exposed_ports(8194)
self.dify_sandbox.env = {
"API_KEY": "test_api_key",
@@ -187,7 +185,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...")
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.3-local").with_network(
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,245 @@
"""Unit tests for inner_api app DSL import/export endpoints.
Tests Pydantic model validation, endpoint handler logic, and the
_get_active_account helper. Auth/setup decorators are tested separately
in test_auth_wraps.py; handler tests use inspect.unwrap() to bypass them.
"""
import inspect
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.app.dsl import (
EnterpriseAppDSLExport,
EnterpriseAppDSLImport,
InnerAppDSLImportPayload,
_get_active_account,
)
from services.app_dsl_service import ImportStatus
class TestInnerAppDSLImportPayload:
"""Test InnerAppDSLImportPayload Pydantic model validation."""
def test_valid_payload_all_fields(self):
data = {
"yaml_content": "version: 0.6.0\nkind: app\n",
"creator_email": "user@example.com",
"name": "My App",
"description": "A test app",
}
payload = InnerAppDSLImportPayload.model_validate(data)
assert payload.yaml_content == data["yaml_content"]
assert payload.creator_email == "user@example.com"
assert payload.name == "My App"
assert payload.description == "A test app"
def test_valid_payload_optional_fields_omitted(self):
data = {
"yaml_content": "version: 0.6.0\n",
"creator_email": "user@example.com",
}
payload = InnerAppDSLImportPayload.model_validate(data)
assert payload.name is None
assert payload.description is None
def test_missing_yaml_content_fails(self):
with pytest.raises(ValidationError) as exc_info:
InnerAppDSLImportPayload.model_validate({"creator_email": "a@b.com"})
assert "yaml_content" in str(exc_info.value)
def test_missing_creator_email_fails(self):
with pytest.raises(ValidationError) as exc_info:
InnerAppDSLImportPayload.model_validate({"yaml_content": "test"})
assert "creator_email" in str(exc_info.value)
class TestGetActiveAccount:
"""Test the _get_active_account helper function."""
@patch("controllers.inner_api.app.dsl.db")
def test_returns_active_account(self, mock_db):
mock_account = MagicMock()
mock_account.status = "active"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
result = _get_active_account("user@example.com")
assert result is mock_account
mock_db.session.query.return_value.filter_by.assert_called_once_with(email="user@example.com")
@patch("controllers.inner_api.app.dsl.db")
def test_returns_none_for_inactive_account(self, mock_db):
mock_account = MagicMock()
mock_account.status = "banned"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
result = _get_active_account("banned@example.com")
assert result is None
@patch("controllers.inner_api.app.dsl.db")
def test_returns_none_for_nonexistent_email(self, mock_db):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
result = _get_active_account("missing@example.com")
assert result is None
class TestEnterpriseAppDSLImport:
"""Test EnterpriseAppDSLImport endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseAppDSLImport()
@pytest.fixture
def _mock_import_deps(self):
"""Patch db, Session, and AppDslService for import handler tests."""
with (
patch("controllers.inner_api.app.dsl.db"),
patch("controllers.inner_api.app.dsl.Session") as mock_session,
patch("controllers.inner_api.app.dsl.AppDslService") as mock_dsl_cls,
):
mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_session.return_value.__exit__ = MagicMock(return_value=False)
self._mock_dsl = MagicMock()
mock_dsl_cls.return_value = self._mock_dsl
yield
def _make_import_result(self, status: ImportStatus, **kwargs) -> "Import":
from services.app_dsl_service import Import
result = Import(
id="import-id",
status=status,
app_id=kwargs.get("app_id", "app-123"),
app_mode=kwargs.get("app_mode", "workflow"),
)
return result
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_success_returns_200(self, mock_get_account, api_instance, app: Flask):
mock_account = MagicMock()
mock_get_account.return_value = mock_account
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.COMPLETED)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {
"yaml_content": "version: 0.6.0\n",
"creator_email": "user@example.com",
}
result = unwrapped(api_instance, workspace_id="ws-123")
body, status_code = result
assert status_code == 200
assert body["status"] == "completed"
mock_account.set_tenant_id.assert_called_once_with("ws-123")
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_pending_returns_202(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = MagicMock()
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.PENDING)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
assert status_code == 202
assert body["status"] == "pending"
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_failed_returns_400(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = MagicMock()
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.FAILED)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
assert status_code == 400
assert body["status"] == "failed"
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_account_not_found_returns_404(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = None
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "missing@e.com"}
result = unwrapped(api_instance, workspace_id="ws-123")
body, status_code = result
assert status_code == 404
assert "missing@e.com" in body["message"]
class TestEnterpriseAppDSLExport:
"""Test EnterpriseAppDSLExport endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseAppDSLExport()
@patch("controllers.inner_api.app.dsl.AppDslService")
@patch("controllers.inner_api.app.dsl.db")
def test_export_success_returns_200(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
mock_app = MagicMock()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
mock_dsl_cls.export_dsl.return_value = "version: 0.6.0\nkind: app\n"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=false"):
result = unwrapped(api_instance, app_id="app-123")
body, status_code = result
assert status_code == 200
assert body["data"] == "version: 0.6.0\nkind: app\n"
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=False)
@patch("controllers.inner_api.app.dsl.AppDslService")
@patch("controllers.inner_api.app.dsl.db")
def test_export_with_secret(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
mock_app = MagicMock()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
mock_dsl_cls.export_dsl.return_value = "yaml-data"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=true"):
result = unwrapped(api_instance, app_id="app-123")
body, status_code = result
assert status_code == 200
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=True)
@patch("controllers.inner_api.app.dsl.db")
def test_export_app_not_found_returns_404(self, mock_db, api_instance, app: Flask):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=false"):
result = unwrapped(api_instance, app_id="nonexistent")
body, status_code = result
assert status_code == 404
assert "app not found" in body["message"]

2
api/uv.lock generated
View File

@@ -1457,7 +1457,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.13.2"
version = "1.13.3"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.2
image: langgenius/dify-web:1.13.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -245,7 +245,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
# The DifySandbox configurations
@@ -269,7 +269,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@@ -97,7 +97,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
env_file:
- ./middleware.env
@@ -123,7 +123,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
env_file:
- ./middleware.env

View File

@@ -731,7 +731,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -773,7 +773,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -812,7 +812,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -842,7 +842,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.2
image: langgenius/dify-web:1.13.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -955,7 +955,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
# The DifySandbox configurations
@@ -979,7 +979,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@@ -5,7 +5,8 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
python_path: /opt/python/bin/python3
nodejs_path: /usr/local/bin/node
enable_network: True # please make sure there is no network risk in your environment
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
proxy:

View File

@@ -5,7 +5,7 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
python_path: /opt/python/bin/python3
python_lib_path:
- /usr/local/lib/python3.10
- /usr/lib/python3.10

View File

@@ -0,0 +1,350 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import WorkflowApp from '../index'
const mockSetTriggerStatuses = vi.fn()
const mockSetInputs = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockDebouncedCancel = vi.fn()
const mockFetchRunDetail = vi.fn()
const mockInitialNodes = vi.fn()
const mockInitialEdges = vi.fn()
const mockGetWorkflowRunAndTraceUrl = vi.fn()
let appStoreState: {
appDetail?: {
id: string
mode: string
}
}
let workflowInitState: {
data: {
graph: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: { x: number, y: number, zoom: number }
}
features: Record<string, unknown>
} | null
isLoading: boolean
fileUploadConfigResponse: Record<string, unknown> | null
}
let appContextState: {
isLoadingCurrentWorkspace: boolean
currentWorkspace: {
id?: string
}
}
let appTriggersState: {
data?: {
data: Array<{
node_id: string
status: string
}>
}
}
let searchParamsValue: string | null = null
const mockWorkflowStore = {
setState: mockWorkflowStoreSetState,
getState: () => ({
setInputs: mockSetInputs,
setShowInputsPanel: mockSetShowInputsPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
debouncedSyncWorkflowDraft: {
cancel: mockDebouncedCancel,
},
}),
}
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatuses: mockSetTriggerStatuses,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => appContextState,
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null),
}),
}))
vi.mock('@/service/log', () => ({
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
}))
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => appTriggersState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({
useWorkflowInit: () => workflowInitState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({
useGetRunAndTraceUrl: () => ({
getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (...args: unknown[]) => mockInitialNodes(...args),
initialEdges: (...args: unknown[]) => mockInitialEdges(...args),
}
})
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/features', () => ({
FeaturesProvider: ({
features,
children,
}: {
features: Record<string, unknown>
children: ReactNode
}) => (
<div data-testid="features-provider" data-features={JSON.stringify(features)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({
nodes,
edges,
children,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
children: ReactNode
}) => (
<div data-testid="workflow-default-context" data-nodes={JSON.stringify(nodes)} data-edges={JSON.stringify(edges)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({
children,
}: {
injectWorkflowStoreSliceFn: unknown
children: ReactNode
}) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
default: ({
nodes,
edges,
viewport,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: Record<string, unknown>
}) => (
<div
data-testid="workflow-app-main"
data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)}
data-viewport={JSON.stringify(viewport)}
/>
),
}))
describe('WorkflowApp', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-1',
mode: 'workflow',
},
}
workflowInitState = {
data: {
graph: {
nodes: [{ id: 'raw-node' }],
edges: [{ id: 'raw-edge' }],
viewport: { x: 1, y: 2, zoom: 3 },
},
features: {
file_upload: {
enabled: true,
},
},
},
isLoading: false,
fileUploadConfigResponse: { enabled: true },
}
appContextState = {
isLoadingCurrentWorkspace: false,
currentWorkspace: { id: 'workspace-1' },
}
appTriggersState = {}
searchParamsValue = null
mockFetchRunDetail.mockResolvedValue({ inputs: null })
mockInitialNodes.mockReturnValue([{ id: 'node-1' }])
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
})
it('should render the loading shell while workflow data is still loading', () => {
workflowInitState = {
data: null,
isLoading: true,
fileUploadConfigResponse: null,
}
render(<WorkflowApp />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-app-main')).not.toBeInTheDocument()
})
it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
appTriggersState = {
data: {
data: [
{ node_id: 'trigger-enabled', status: 'enabled' },
{ node_id: 'trigger-disabled', status: 'paused' },
],
},
}
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-nodes', JSON.stringify([{ id: 'node-1' }]))
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-edges', JSON.stringify([{ id: 'edge-1' }]))
expect(screen.getByTestId('workflow-app-main')).toHaveAttribute('data-viewport', JSON.stringify({ x: 1, y: 2, zoom: 3 }))
expect(screen.getByTestId('features-provider')).toBeInTheDocument()
expect(mockSetTriggerStatuses).toHaveBeenCalledWith({
'trigger-enabled': 'enabled',
'trigger-disabled': 'disabled',
})
})
it('should not sync trigger statuses when trigger data is unavailable', () => {
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument()
expect(mockSetTriggerStatuses).not.toHaveBeenCalled()
})
it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => {
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}',
})
const { unmount } = render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
expect(mockSetInputs).toHaveBeenCalledWith({
foo: 'bar',
count: 2,
flag: true,
obj: '{"nested":true}',
nil: '',
})
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
})
unmount()
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: false })
expect(mockDebouncedCancel).toHaveBeenCalled()
})
it('should skip replay lookups when replayRunId is missing', () => {
render(<WorkflowApp />)
expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled()
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should skip replay fetches when the resolved run url is empty', async () => {
searchParamsValue = 'run-1'
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '' })
render(<WorkflowApp />)
await waitFor(() => {
expect(mockGetWorkflowRunAndTraceUrl).toHaveBeenCalledWith('run-1')
})
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should stop replay recovery when workflow run inputs cannot be parsed', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{invalid-json}',
})
render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to parse workflow run inputs',
expect.any(Error),
)
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should ignore replay inputs when they only contain sys variables', async () => {
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{"sys.query":"hidden","sys.user_id":"u-1"}',
})
render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
})
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,90 @@
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from '../utils'
describe('workflow-app utils', () => {
it('should map trigger statuses to enabled and disabled states', () => {
expect(buildTriggerStatusMap([
{ node_id: 'node-1', status: 'enabled' },
{ node_id: 'node-2', status: 'disabled' },
{ node_id: 'node-3', status: 'paused' },
])).toEqual({
'node-1': 'enabled',
'node-2': 'disabled',
'node-3': 'disabled',
})
})
it('should coerce replay run inputs, omit sys keys, and stringify complex values', () => {
expect(coerceReplayUserInputs({
'sys.query': 'hidden',
'query': 'hello',
'count': 3,
'enabled': true,
'nullable': null,
'metadata': { nested: true },
})).toEqual({
query: 'hello',
count: 3,
enabled: true,
nullable: '',
metadata: '{"nested":true}',
})
expect(coerceReplayUserInputs('invalid')).toBeNull()
expect(coerceReplayUserInputs(null)).toBeNull()
})
it('should build initial features with file-upload and feature fallbacks', () => {
const result = buildInitialFeatures({
file_upload: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
image: {
enabled: true,
number_limits: 5,
transfer_methods: [TransferMethod.remote_url],
},
},
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
speech_to_text: { enabled: true },
text_to_speech: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
}, { enabled: true } as never)
expect(result).toMatchObject({
file: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
fileUploadConfig: { enabled: true },
image: {
enabled: true,
number_limits: 5,
transfer_methods: [TransferMethod.remote_url],
},
},
opening: {
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
},
suggested: { enabled: true },
speech2text: { enabled: true },
text2speech: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
})
})
})

View File

@@ -0,0 +1,494 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowChildren from '../workflow-children'
type WorkflowStoreState = {
showFeaturesPanel: boolean
showImportDSLModal: boolean
setShowImportDSLModal: (show: boolean) => void
showOnboarding: boolean
setShowOnboarding: (show: boolean) => void
setHasSelectedStartNode: (selected: boolean) => void
setShouldAutoOpenStartNodeSelector: (open: boolean) => void
}
type TriggerPluginConfig = {
plugin_id: string
provider_name: string
provider_type: string
event_name: string
event_label: string
event_description: string
output_schema: Record<string, unknown>
paramSchemas: Array<Record<string, unknown>>
params: Record<string, unknown>
subscription_id: string
plugin_unique_identifier: string
is_team_authorization: boolean
meta?: Record<string, unknown>
}
const mockSetShowImportDSLModal = vi.fn()
const mockSetShowOnboarding = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
const mockSetNodes = vi.fn()
const mockSetEdges = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleOnboardingClose = vi.fn()
const mockHandlePaneContextmenuCancel = vi.fn()
const mockHandleExportDSL = vi.fn()
const mockExportCheck = vi.fn()
const mockAutoGenerateWebhookUrl = vi.fn()
let workflowStoreState: WorkflowStoreState
let eventSubscription: ((value: { type: string, payload: { data: Array<Record<string, unknown>> } }) => void) | null = null
let lastGenerateNodeInput: Record<string, unknown> | null = null
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
setNodes: mockSetNodes,
setEdges: mockSetEdges,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: typeof eventSubscription) => {
eventSubscription = callback
},
},
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl,
useDSL: () => ({
exportCheck: mockExportCheck,
handleExportDSL: mockHandleExportDSL,
}),
usePanelInteractions: () => ({
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
generateNewNode: (args: Record<string, unknown>) => {
lastGenerateNodeInput = args
return {
newNode: {
id: 'new-node-id',
position: args.position,
data: args.data,
},
}
},
}
})
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Start]: {
defaultValue: {
title: 'Start Title',
desc: 'Start description',
config: {
image: false,
},
},
},
[BlockEnum.TriggerPlugin]: {
defaultValue: {
title: 'Plugin title',
desc: 'Plugin description',
config: {
baseConfig: 'base',
},
},
},
},
}),
}))
vi.mock('@/app/components/workflow-app/hooks/use-auto-onboarding', () => ({
useAutoOnboarding: () => ({
handleOnboardingClose: mockHandleOnboardingClose,
}),
}))
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency">plugin-dependency</div>,
}))
vi.mock('@/app/components/workflow-app/components/workflow-header', () => ({
default: () => <div data-testid="workflow-header">workflow-header</div>,
}))
vi.mock('@/app/components/workflow-app/components/workflow-panel', () => ({
default: () => <div data-testid="workflow-panel">workflow-panel</div>,
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
vi.mock('@/app/components/workflow/features', () => ({
default: () => <div data-testid="workflow-features">features</div>,
}))
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
default: ({
onCancel,
onBackup,
onImport,
}: {
onCancel: () => void
onBackup: () => void
onImport: () => void
}) => (
<div data-testid="update-dsl-modal">
<button type="button" onClick={onCancel}>cancel-import-dsl</button>
<button type="button" onClick={onBackup}>backup-dsl</button>
<button type="button" onClick={onImport}>import-dsl</button>
</div>
),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({
envList,
onConfirm,
onClose,
}: {
envList: Array<Record<string, unknown>>
onConfirm: () => void
onClose: () => void
}) => (
<div data-testid="dsl-export-confirm-modal" data-env-count={String(envList.length)}>
<button type="button" onClick={onConfirm}>confirm-export-dsl</button>
<button type="button" onClick={onClose}>close-export-dsl</button>
</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', () => ({
default: ({
onClose,
onSelectStartNode,
}: {
isShow: boolean
onClose: () => void
onSelectStartNode: (nodeType: BlockEnum, config?: TriggerPluginConfig) => void
}) => (
<div data-testid="workflow-onboarding-modal">
<button type="button" onClick={onClose}>close-onboarding</button>
<button type="button" onClick={() => onSelectStartNode(BlockEnum.Start)}>select-start-node</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.Start, {
title: 'Configured Start Title',
desc: 'Configured Start Description',
config: { image: true, custom: 'config' },
extra: 'field',
} as never)}
>
select-start-node-with-config
</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
plugin_id: 'plugin-id',
provider_name: 'provider-name',
provider_type: 'tool',
event_name: 'event-name',
event_label: 'Event Label',
event_description: 'Event Description',
output_schema: { output: true },
paramSchemas: [{ name: 'api_key' }],
params: { token: 'abc' },
subscription_id: 'subscription-id',
plugin_unique_identifier: 'plugin-unique',
is_team_authorization: true,
meta: { source: 'plugin' },
})}
>
select-trigger-plugin
</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
plugin_id: 'plugin-id-2',
provider_name: 'provider-name-2',
provider_type: 'tool',
event_name: 'event-name-2',
event_label: '',
event_description: '',
output_schema: {},
paramSchemas: undefined,
params: {},
subscription_id: 'subscription-id-2',
plugin_unique_identifier: 'plugin-unique-2',
is_team_authorization: false,
} as never)}
>
select-trigger-plugin-fallback
</button>
</div>
),
}))
describe('WorkflowChildren', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = {
showFeaturesPanel: false,
showImportDSLModal: false,
setShowImportDSLModal: mockSetShowImportDSLModal,
showOnboarding: false,
setShowOnboarding: mockSetShowOnboarding,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
}
eventSubscription = null
lastGenerateNodeInput = null
mockHandleSyncWorkflowDraft.mockImplementation((_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => {
callback?.onSuccess?.()
})
})
it('should render feature panel, import modal actions, and default workflow chrome', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showFeaturesPanel: true,
showImportDSLModal: true,
}
render(<WorkflowChildren />)
expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
expect(await screen.findByTestId('workflow-features')).toBeInTheDocument()
expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /cancel-import-dsl/i }))
await user.click(screen.getByRole('button', { name: /backup-dsl/i }))
await user.click(screen.getByRole('button', { name: /^import-dsl$/i }))
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
expect(mockExportCheck).toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
})
it('should react to DSL export check events by showing the confirm modal and closing it', async () => {
const user = userEvent.setup()
render(<WorkflowChildren />)
await act(async () => {
eventSubscription?.({
type: DSL_EXPORT_CHECK,
payload: {
data: [{ id: 'env-1' }, { id: 'env-2' }],
},
})
})
expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute('data-env-count', '2')
await user.click(screen.getByRole('button', { name: /confirm-export-dsl/i }))
await user.click(screen.getByRole('button', { name: /close-export-dsl/i }))
expect(mockHandleExportDSL).toHaveBeenCalled()
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should ignore unrelated workflow events when listening for DSL export checks', async () => {
render(<WorkflowChildren />)
await act(async () => {
eventSubscription?.({
type: 'UNRELATED_EVENT',
payload: {
data: [{ id: 'env-1' }],
},
})
})
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should close onboarding through the onboarding hook callback', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
expect(await screen.findByTestId('workflow-onboarding-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /close-onboarding/i }))
expect(mockHandleOnboardingClose).toHaveBeenCalled()
})
it('should create a start node, sync draft, and auto-generate webhook url after selecting a start node', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /^select-start-node$/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Start Title',
desc: 'Start description',
config: {
image: false,
},
},
})
expect(mockSetNodes).toHaveBeenCalledWith([expect.objectContaining({ id: 'new-node-id' })])
expect(mockSetEdges).toHaveBeenCalledWith([])
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, false, expect.any(Object))
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('new-node-id')
})
it('should merge non-trigger start node config directly into the default node data', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /select-start-node-with-config/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Configured Start Title',
desc: 'Configured Start Description',
config: {
image: true,
custom: 'config',
},
extra: 'field',
},
})
})
it('should merge trigger plugin defaults and config before creating the node', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /^select-trigger-plugin$/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
plugin_id: 'plugin-id',
provider_id: 'provider-name',
provider_name: 'provider-name',
provider_type: 'tool',
event_name: 'event-name',
event_label: 'Event Label',
event_description: 'Event Description',
title: 'Event Label',
desc: 'Event Description',
output_schema: { output: true },
parameters_schema: [{ name: 'api_key' }],
config: {
baseConfig: 'base',
token: 'abc',
},
subscription_id: 'subscription-id',
plugin_unique_identifier: 'plugin-unique',
is_team_authorization: true,
meta: { source: 'plugin' },
},
})
})
it('should fall back to plugin default title and description when trigger labels are missing', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /select-trigger-plugin-fallback/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Plugin title',
desc: 'Plugin description',
parameters_schema: [],
config: {
baseConfig: 'base',
},
},
})
})
})

View File

@@ -0,0 +1,277 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import WorkflowMain from '../workflow-main'
const mockSetFeatures = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const hookFns = {
doSyncWorkflowDraft: vi.fn(),
syncWorkflowDraftWhenPageClose: vi.fn(),
handleRefreshWorkflowDraft: vi.fn(),
handleBackupDraft: vi.fn(),
handleLoadBackupDraft: vi.fn(),
handleRestoreFromPublishedWorkflow: vi.fn(),
handleRun: vi.fn(),
handleStopRun: vi.fn(),
handleStartWorkflowRun: vi.fn(),
handleWorkflowStartRunInChatflow: vi.fn(),
handleWorkflowStartRunInWorkflow: vi.fn(),
handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(),
handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(),
handleWorkflowTriggerPluginRunInWorkflow: vi.fn(),
handleWorkflowRunAllTriggersInWorkflow: vi.fn(),
getWorkflowRunAndTraceUrl: vi.fn(),
exportCheck: vi.fn(),
handleExportDSL: vi.fn(),
fetchInspectVars: vi.fn(),
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
let capturedContextProps: Record<string, unknown> | null = null
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate'> & {
hooksStore?: Record<string, unknown>
children?: ReactNode
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
setFeatures: mockSetFeatures,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setConversationVariables: mockSetConversationVariables,
setEnvironmentVariables: mockSetEnvironmentVariables,
}),
}),
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
nodes,
edges,
viewport,
onWorkflowDataUpdate,
hooksStore,
children,
}: MockWorkflowWithInnerContextProps) => {
capturedContextProps = {
nodes,
edges,
viewport,
hooksStore,
}
return (
<div data-testid="workflow-inner-context">
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({
features: { file: { enabled: true } },
conversation_variables: [{ id: 'conversation-1' }],
environment_variables: [{ id: 'env-1' }],
})}
>
update-workflow-data
</button>
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({
conversation_variables: [{ id: 'conversation-only' }],
})}
>
update-conversation-only
</button>
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({})}
>
update-empty-payload
</button>
{children}
</div>
)
},
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }),
useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }),
useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }),
useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }),
useInspectVarsCrud: () => ({
hasNodeInspectVars: hookFns.hasNodeInspectVars,
hasSetInspectVar: hookFns.hasSetInspectVar,
fetchInspectVarValue: hookFns.fetchInspectVarValue,
editInspectVarValue: hookFns.editInspectVarValue,
renameInspectVarName: hookFns.renameInspectVarName,
appendNodeInspectVars: hookFns.appendNodeInspectVars,
deleteInspectVar: hookFns.deleteInspectVar,
deleteNodeInspectorVars: hookFns.deleteNodeInspectorVars,
deleteAllInspectorVars: hookFns.deleteAllInspectorVars,
isInspectVarEdited: hookFns.isInspectVarEdited,
resetToLastRunVar: hookFns.resetToLastRunVar,
invalidateSysVarValues: hookFns.invalidateSysVarValues,
resetConversationVar: hookFns.resetConversationVar,
invalidateConversationVarValues: hookFns.invalidateConversationVarValues,
}),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
}),
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: hookFns.fetchInspectVars,
}),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleBackupDraft: hookFns.handleBackupDraft,
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
handleRun: hookFns.handleRun,
handleStopRun: hookFns.handleStopRun,
}),
useWorkflowStartRun: () => ({
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
}),
}))
vi.mock('../workflow-children', () => ({
default: () => <div data-testid="workflow-children">workflow-children</div>,
}))
describe('WorkflowMain', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedContextProps = null
})
it('should render the inner workflow context with children and forwarded graph props', () => {
const nodes = [{ id: 'node-1' }]
const edges = [{ id: 'edge-1' }]
const viewport = { x: 1, y: 2, zoom: 1.5 }
render(
<WorkflowMain
nodes={nodes as never}
edges={edges as never}
viewport={viewport}
/>,
)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-children')).toBeInTheDocument()
expect(capturedContextProps).toMatchObject({
nodes,
edges,
viewport,
})
})
it('should update features and workflow variables when workflow data changes', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i }))
expect(mockSetFeatures).toHaveBeenCalledWith({ file: { enabled: true } })
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-1' }])
})
it('should only update the workflow store slices present in the payload', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i }))
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-only' }])
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('should ignore empty workflow data updates', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-empty-payload/i }))
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetConversationVariables).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('should expose the composed workflow action hooks through hooksStore', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
expect(capturedContextProps?.hooksStore).toMatchObject({
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft,
handleBackupDraft: hookFns.handleBackupDraft,
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
handleRun: hookFns.handleRun,
handleStopRun: hookFns.handleStopRun,
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData: { nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } },
getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl,
exportCheck: hookFns.exportCheck,
handleExportDSL: hookFns.handleExportDSL,
fetchInspectVars: hookFns.fetchInspectVars,
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
})
})
})

View File

@@ -0,0 +1,214 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import WorkflowPanel from '../workflow-panel'
type AppStoreState = {
appDetail?: {
id?: string
workflow?: {
id?: string
}
}
currentLogItem?: { id: string }
setCurrentLogItem: (item?: { id: string }) => void
showMessageLogModal: boolean
setShowMessageLogModal: (show: boolean) => void
currentLogModalActiveTab?: string
}
type WorkflowStoreState = {
historyWorkflowData?: Record<string, unknown>
showDebugAndPreviewPanel: boolean
showChatVariablePanel: boolean
showGlobalVariablePanel: boolean
}
const mockUseIsChatMode = vi.fn()
const mockSetCurrentLogItem = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
let appStoreState: AppStoreState
let workflowStoreState: WorkflowStoreState
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: AppStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({
components,
versionHistoryPanelProps,
}: {
components?: {
left?: ReactNode
right?: ReactNode
}
versionHistoryPanelProps?: {
getVersionListUrl: string
deleteVersionUrl: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl: (versionId: string) => string
latestVersionId?: string
}
}) => (
<div
data-testid="panel"
data-version-list-url={versionHistoryPanelProps?.getVersionListUrl ?? ''}
data-delete-version-url={versionHistoryPanelProps?.deleteVersionUrl('version-1') ?? ''}
data-restore-version-url={versionHistoryPanelProps?.restoreVersionUrl('version-1') ?? ''}
data-update-version-url={versionHistoryPanelProps?.updateVersionUrl('version-1') ?? ''}
data-latest-version-id={versionHistoryPanelProps?.latestVersionId ?? ''}
>
<div data-testid="panel-left">{components?.left}</div>
<div data-testid="panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => {
const LazyComp = React.lazy(loader)
return function DynamicWrapper(props: Record<string, unknown>) {
return React.createElement(
React.Suspense,
{ fallback: null },
React.createElement(LazyComp, props),
)
}
},
}))
vi.mock('@/app/components/base/message-log-modal', () => ({
default: ({
currentLogItem,
defaultTab,
onCancel,
}: {
currentLogItem?: { id: string }
defaultTab?: string
onCancel: () => void
}) => (
<div data-testid="message-log-modal" data-current-log-id={currentLogItem?.id ?? ''} data-default-tab={defaultTab ?? ''}>
<button type="button" onClick={onCancel}>close-message-log</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/record', () => ({
default: () => <div data-testid="record-panel">record</div>,
}))
vi.mock('@/app/components/workflow/panel/chat-record', () => ({
default: () => <div data-testid="chat-record-panel">chat-record</div>,
}))
vi.mock('@/app/components/workflow/panel/debug-and-preview', () => ({
default: () => <div data-testid="debug-and-preview-panel">debug</div>,
}))
vi.mock('@/app/components/workflow/panel/workflow-preview', () => ({
default: () => <div data-testid="workflow-preview-panel">preview</div>,
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel', () => ({
default: () => <div data-testid="chat-variable-panel">chat-variable</div>,
}))
vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({
default: () => <div data-testid="global-variable-panel">global-variable</div>,
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
describe('WorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-123',
workflow: {
id: 'workflow-version-id',
},
},
currentLogItem: { id: 'log-1' },
setCurrentLogItem: mockSetCurrentLogItem,
showMessageLogModal: false,
setShowMessageLogModal: mockSetShowMessageLogModal,
currentLogModalActiveTab: 'detail',
}
workflowStoreState = {
historyWorkflowData: undefined,
showDebugAndPreviewPanel: false,
showChatVariablePanel: false,
showGlobalVariablePanel: false,
}
mockUseIsChatMode.mockReturnValue(false)
})
it('should configure workflow version history urls and latest version id for the panel shell', async () => {
render(<WorkflowPanel />)
const panel = await screen.findByTestId('panel')
expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows')
expect(panel).toHaveAttribute('data-delete-version-url', '/apps/app-123/workflows/version-1')
expect(panel).toHaveAttribute('data-restore-version-url', '/apps/app-123/workflows/version-1/restore')
expect(panel).toHaveAttribute('data-update-version-url', '/apps/app-123/workflows/version-1')
expect(panel).toHaveAttribute('data-latest-version-id', 'workflow-version-id')
})
it('should render and close the message log modal from the left panel slot', async () => {
const user = userEvent.setup()
appStoreState = {
...appStoreState,
showMessageLogModal: true,
}
render(<WorkflowPanel />)
expect(await screen.findByTestId('message-log-modal')).toHaveAttribute('data-current-log-id', 'log-1')
expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail')
await user.click(screen.getByRole('button', { name: /close-message-log/i }))
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
})
it('should switch right-side workflow panels based on chat mode and workflow state', async () => {
workflowStoreState = {
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showChatVariablePanel: true,
showGlobalVariablePanel: true,
}
mockUseIsChatMode.mockReturnValue(true)
const { unmount } = render(<WorkflowPanel />)
expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument()
expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('chat-variable-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('workflow-preview-panel')).not.toBeInTheDocument()
unmount()
mockUseIsChatMode.mockReturnValue(false)
render(<WorkflowPanel />)
expect(await screen.findByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.queryByTestId('chat-record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('debug-and-preview-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('chat-variable-panel')).not.toBeInTheDocument()
})
})

View File

@@ -149,6 +149,7 @@ const createProviderContext = ({
const renderWithToast = (ui: ReactElement) => {
return render(
// eslint-disable-next-line react/no-context-provider
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{ui}
</ToastContext.Provider>,
@@ -445,6 +446,27 @@ describe('FeaturesTrigger', () => {
})
})
it('should skip success side effects when publish mutation returns no workflow version', async () => {
// Arrange
const user = userEvent.setup()
mockPublishWorkflow.mockResolvedValue(null)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
expect(mockNotify).not.toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled()
expect(mockInvalidateAppTriggers).not.toHaveBeenCalled()
expect(mockSetPublishedAt).not.toHaveBeenCalled()
expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled()
expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled()
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()

View File

@@ -0,0 +1,18 @@
import * as hooks from '../index'
describe('workflow-app hooks index', () => {
it('should re-export workflow-app hooks', () => {
expect(hooks.useAvailableNodesMetaData).toBeTypeOf('function')
expect(hooks.useConfigsMap).toBeTypeOf('function')
expect(hooks.useDSL).toBeTypeOf('function')
expect(hooks.useGetRunAndTraceUrl).toBeTypeOf('function')
expect(hooks.useInspectVarsCrud).toBeTypeOf('function')
expect(hooks.useIsChatMode).toBeTypeOf('function')
expect(hooks.useNodesSyncDraft).toBeTypeOf('function')
expect(hooks.useWorkflowInit).toBeTypeOf('function')
expect(hooks.useWorkflowRefreshDraft).toBeTypeOf('function')
expect(hooks.useWorkflowRun).toBeTypeOf('function')
expect(hooks.useWorkflowStartRun).toBeTypeOf('function')
expect(hooks.useWorkflowTemplate).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,206 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { useDSL } from '../use-DSL'
const mockNotify = vi.fn()
const mockEmit = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockExportAppConfig = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDownloadBlob = vi.fn()
let appStoreState: {
appDetail?: {
id: string
name: string
}
}
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
vi.mock('@/service/apps', () => ({
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
const createDeferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useDSL', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-1',
name: 'Workflow App',
},
}
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
})
it('should export workflow dsl and download the yaml blob when no secret env is present', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
workflowID: undefined,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
data: expect.any(Blob),
fileName: 'Workflow App.yml',
}))
})
it('should forward include and workflow id arguments when exporting dsl directly', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true, 'workflow-1')
})
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: true,
workflowID: 'workflow-1',
})
})
it('should emit DSL_EXPORT_CHECK when secret environment variables exist', async () => {
const secretVars = [{ id: 'env-1', value_type: 'secret', value: 'secret-token' }]
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEmit).toHaveBeenCalledWith({
type: DSL_EXPORT_CHECK,
payload: {
data: secretVars,
},
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
it('should return early when app detail is unavailable', async () => {
appStoreState = {}
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
await result.current.handleExportDSL()
})
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockExportAppConfig).not.toHaveBeenCalled()
expect(mockEmit).not.toHaveBeenCalled()
})
it('should notify when export fails', async () => {
mockExportAppConfig.mockRejectedValue(new Error('export failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
})
it('should notify when exportCheck cannot load the workflow draft', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('draft fetch failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
it('should ignore repeated export attempts while an export is already in progress', async () => {
const deferred = createDeferred<{ data: string }>()
mockExportAppConfig.mockReturnValue(deferred.promise)
const { result } = renderHook(() => useDSL())
let firstExportPromise!: Promise<void>
act(() => {
firstExportPromise = result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
})
act(() => {
void result.current.handleExportDSL()
})
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
await act(async () => {
deferred.resolve({ data: 'yaml-content' })
await firstExportPromise
})
})
})

View File

@@ -0,0 +1,118 @@
import { act, renderHook } from '@testing-library/react'
import { useAutoOnboarding } from '../use-auto-onboarding'
const mockGetNodes = vi.fn()
const mockWorkflowStore = {
getState: vi.fn(),
}
const mockSetShowOnboarding = vi.fn()
const mockSetHasShownOnboarding = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
describe('useAutoOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockGetNodes.mockReturnValue([])
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: false,
hasShownOnboarding: false,
notInitialWorkflow: false,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: false,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('should open onboarding after the delayed empty-canvas check on mount', () => {
renderHook(() => useAutoOnboarding())
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
})
it('should skip auto onboarding when it is already visible or the workflow is not initial', () => {
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: true,
hasShownOnboarding: false,
notInitialWorkflow: true,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: false,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
renderHook(() => useAutoOnboarding())
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetShowOnboarding).not.toHaveBeenCalled()
expect(mockSetHasShownOnboarding).not.toHaveBeenCalled()
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
})
it('should close onboarding and reset selected start node state when one was chosen', () => {
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: false,
hasShownOnboarding: true,
notInitialWorkflow: false,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: true,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
const { result } = renderHook(() => useAutoOnboarding())
act(() => {
result.current.handleOnboardingClose()
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
})
it('should close onboarding and disable auto-open when no start node was selected', () => {
const { result } = renderHook(() => useAutoOnboarding())
act(() => {
result.current.handleOnboardingClose()
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,49 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
const mockUseIsChatMode = vi.fn()
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `/docs${path}`,
}))
describe('useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should include chat-specific nodes and make the start node undeletable in chat mode', () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(true)
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.End]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap?.[BlockEnum.VariableAggregator])
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain('/docs/use-dify/nodes/')
})
it('should include workflow-specific trigger and end nodes outside chat mode', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(false)
expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.Start]?.defaultValue).toMatchObject({
type: BlockEnum.Start,
title: 'workflow.blocks.start',
})
})
})

View File

@@ -0,0 +1,40 @@
import { renderHook } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { useConfigsMap } from '../use-configs-map'
const mockUseFeatures = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => mockUseFeatures(selector),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T>(selector: (state: { appId: string }) => T) => selector({ appId: 'app-1' }),
}))
describe('useConfigsMap', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFeatures.mockImplementation((selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => selector({
features: {
file: {
enabled: true,
number_limits: 3,
},
},
}))
})
it('should map workflow app id and feature file settings into inspect-var configs', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current).toEqual({
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {
enabled: true,
number_limits: 3,
},
})
})
})

View File

@@ -0,0 +1,28 @@
import { renderHook } from '@testing-library/react'
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
const mockWorkflowStore = {
getState: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
describe('useGetRunAndTraceUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.getState.mockReturnValue({
appId: 'app-123',
})
})
it('should build workflow run and trace urls from the current app id', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
expect(result.current.getWorkflowRunAndTraceUrl('run-1')).toEqual({
runUrl: '/apps/app-123/workflow-runs/run-1',
traceUrl: '/apps/app-123/workflow-runs/run-1/node-executions',
})
})
})

View File

@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react'
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
const mockUseInspectVarsCrudCommon = vi.fn()
const mockUseConfigsMap = vi.fn()
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (...args: unknown[]) => mockUseInspectVarsCrudCommon(...args),
}))
vi.mock('@/app/components/workflow-app/hooks/use-configs-map', () => ({
useConfigsMap: () => mockUseConfigsMap(),
}))
describe('useInspectVarsCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfigsMap.mockReturnValue({
flowId: 'app-1',
flowType: 'app-flow',
fileSettings: { enabled: true },
})
mockUseInspectVarsCrudCommon.mockReturnValue({
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
deleteInspectVar: vi.fn(),
})
})
it('should call the shared inspect vars hook with workflow-app configs and return its api', () => {
const { result } = renderHook(() => useInspectVarsCrud())
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith({
flowId: 'app-1',
flowType: 'app-flow',
fileSettings: { enabled: true },
})
expect(result.current).toEqual({
fetchInspectVarValue: expect.any(Function),
editInspectVarValue: expect.any(Function),
deleteInspectVar: expect.any(Function),
})
})
})

View File

@@ -4,42 +4,57 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
const mockPostWithKeepalive = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockGetNodesReadOnly = vi.fn()
let reactFlowState: {
getNodes: typeof mockGetNodes
edges: Array<Record<string, unknown>>
transform: [number, number, number]
}
let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
syncWorkflowDraftHash: string | null
environmentVariables: Array<Record<string, unknown>>
conversationVariables: Array<Record<string, unknown>>
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
}
let featuresState: {
features: {
opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] }
suggested: Record<string, unknown>
text2speech: Record<string, unknown>
speech2text: Record<string, unknown>
citation: Record<string, unknown>
moderation: Record<string, unknown>
file: Record<string, unknown>
}
}
vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => ({ getNodes: mockGetNodes, edges: [], transform: [0, 0, 1] }) }),
useStoreApi: () => ({ getState: () => reactFlowState }),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: vi.fn(),
setDraftUpdatedAt: vi.fn(),
}),
getState: () => workflowStoreState,
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}),
getState: () => featuresState,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: () => false }),
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
}))
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
@@ -55,7 +70,7 @@ vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p),
}))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: vi.fn() }))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) }))
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
const mockHandleRefreshWorkflowDraft = vi.fn()
@@ -66,6 +81,32 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({
describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => {
beforeEach(() => {
vi.clearAllMocks()
reactFlowState = {
getNodes: mockGetNodes,
edges: [],
transform: [0, 0, 1],
}
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
}
featuresState = {
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start' } }])
mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 })
})
@@ -122,4 +163,102 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
}),
}))
})
it('should strip temp entities and private data, use the latest hash, and invoke success callbacks', async () => {
reactFlowState = {
...reactFlowState,
edges: [
{ id: 'edge-1', source: 'n1', target: 'n2', data: { _isTemp: false, _private: 'drop', stable: 'keep' } },
{ id: 'temp-edge', source: 'n2', target: 'n3', data: { _isTemp: true } },
],
transform: [10, 20, 1.5],
}
mockGetNodes.mockReturnValue([
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', _tempField: 'drop', label: 'Start' } },
{ id: 'temp-node', position: { x: 1, y: 1 }, data: { type: 'answer', _isTempNode: true } },
])
workflowStoreState = {
...workflowStoreState,
syncWorkflowDraftHash: 'latest-hash',
environmentVariables: [{ id: 'env-1', value: 'env' }],
conversationVariables: [{ id: 'conversation-1', value: 'conversation' }],
}
featuresState = {
features: {
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: false },
file: { enabled: true },
},
}
const callbacks = {
onSuccess: vi.fn(),
onError: vi.fn(),
onSettled: vi.fn(),
}
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, callbacks)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: '/apps/app-1/workflows/draft',
params: {
graph: {
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', label: 'Start' } }],
edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }],
viewport: { x: 10, y: 20, zoom: 1.5 },
},
features: {
opening_statement: 'Hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-1', value: 'env' }],
conversation_variables: [{ id: 'conversation-1', value: 'conversation' }],
hash: 'latest-hash',
},
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1)
expect(callbacks.onSuccess).toHaveBeenCalled()
expect(callbacks.onError).not.toHaveBeenCalled()
expect(callbacks.onSettled).toHaveBeenCalled()
})
it('should post workflow draft with keepalive when the page closes', () => {
reactFlowState = {
...reactFlowState,
transform: [1, 2, 3],
}
workflowStoreState = {
...workflowStoreState,
environmentVariables: [{ id: 'env-1' }],
conversationVariables: [{ id: 'conversation-1' }],
}
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({
graph: expect.objectContaining({
viewport: { x: 1, y: 2, zoom: 3 },
}),
hash: 'hash-123',
}))
})
})

View File

@@ -1,5 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { useWorkflowInit } from '../use-workflow-init'
@@ -11,6 +12,21 @@ const mockSetLastPublishedHasUserInput = vi.fn()
const mockSetFileUploadConfig = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockWorkflowStoreGetState = vi.fn()
const mockFetchNodesDefaultConfigs = vi.fn()
const mockFetchPublishedWorkflow = vi.fn()
let appStoreState: {
appDetail: {
id: string
name: string
mode: string
}
}
let workflowConfigState: {
data: Record<string, unknown> | null
isLoading: boolean
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T>(selector: (state: { setSyncWorkflowDraftHash: ReturnType<typeof vi.fn> }) => T): T =>
@@ -22,8 +38,8 @@ vi.mock('@/app/components/workflow/store', () => ({
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: { appDetail: { id: string, name: string, mode: string } }) => T): T =>
selector({ appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' } }),
useStore: <T>(selector: (state: typeof appStoreState) => T): T =>
selector(appStoreState),
}))
vi.mock('../use-workflow-template', () => ({
@@ -31,7 +47,11 @@ vi.mock('../use-workflow-template', () => ({
}))
vi.mock('@/service/use-workflow', () => ({
useWorkflowConfig: () => ({ data: null, isLoading: false }),
useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => {
if (workflowConfigState.data)
onSuccess(workflowConfigState.data)
return workflowConfigState
},
}))
const mockFetchWorkflowDraft = vi.fn()
@@ -40,8 +60,8 @@ const mockSyncWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
fetchNodesDefaultConfigs: () => Promise.resolve([]),
fetchPublishedWorkflow: () => Promise.resolve({ created_at: 0, graph: { nodes: [], edges: [] } }),
fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args),
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
}))
const notExistError = () => ({
@@ -68,6 +88,10 @@ const draftResponse = {
describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' },
}
workflowConfigState = { data: null, isLoading: false }
mockWorkflowStoreGetState.mockReturnValue({
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setToolPublished: mockSetToolPublished,
@@ -75,6 +99,8 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
setFileUploadConfig: mockSetFileUploadConfig,
})
mockFetchNodesDefaultConfigs.mockResolvedValue([])
mockFetchPublishedWorkflow.mockResolvedValue({ created_at: 0, graph: { nodes: [], edges: [] } })
mockFetchWorkflowDraft
.mockRejectedValueOnce(notExistError())
.mockResolvedValueOnce(draftResponse)
@@ -104,4 +130,77 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
expect(order).toContain('hash:new-hash')
expect(order.indexOf('hash:new-hash')).toBeLessThan(order.indexOf('fetch:2'))
})
it('should hydrate draft state, preload defaults, and derive published workflow metadata on success', async () => {
workflowConfigState = {
data: { enabled: true, sizeLimit: 20 },
isLoading: false,
}
mockFetchWorkflowDraft.mockReset().mockResolvedValue({
...draftResponse,
updated_at: 9,
tool_published: true,
environment_variables: [
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversation_variables: [{ id: 'conversation-1' }],
})
mockFetchNodesDefaultConfigs.mockResolvedValue([
{ type: 'start', config: { title: 'Start Config' } },
{ type: 'start', config: { title: 'Ignored Duplicate' } },
])
mockFetchPublishedWorkflow.mockResolvedValue({
created_at: 99,
graph: {
nodes: [{ id: 'start', data: { type: BlockEnum.Start } }],
edges: [{ source: 'start', target: 'end' }],
},
})
const { result } = renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(result.current.data?.hash).toBe('server-hash')
})
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ appId: 'app-1', appName: 'Test' })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({
envSecrets: { 'env-secret': 'top-secret' },
environmentVariables: [
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversationVariables: [{ id: 'conversation-1' }],
isWorkflowDataLoaded: true,
}))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
start: { title: 'Start Config' },
},
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(9)
expect(mockSetToolPublished).toHaveBeenCalledWith(true)
expect(mockSetPublishedAt).toHaveBeenCalledWith(99)
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockSetFileUploadConfig).toHaveBeenCalledWith({ enabled: true, sizeLimit: 20 })
expect(result.current.fileUploadConfigResponse).toEqual({ enabled: true, sizeLimit: 20 })
expect(result.current.isLoading).toBe(false)
})
it('should fall back to no published user input when preload requests fail', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed'))
renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false)
})
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})

View File

@@ -1,24 +1,32 @@
import { act, renderHook } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft'
const mockHandleUpdateWorkflowCanvas = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetIsWorkflowDataLoaded = vi.fn()
const mockCancel = vi.fn()
let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
debouncedSyncWorkflowDraft?: { cancel: () => void }
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setIsSyncingWorkflowDraft: typeof mockSetIsSyncingWorkflowDraft
setEnvironmentVariables: typeof mockSetEnvironmentVariables
setEnvSecrets: typeof mockSetEnvSecrets
setConversationVariables: typeof mockSetConversationVariables
setIsWorkflowDataLoaded: typeof mockSetIsWorkflowDataLoaded
}
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: vi.fn(),
setEnvironmentVariables: vi.fn(),
setEnvSecrets: vi.fn(),
setConversationVariables: vi.fn(),
setIsWorkflowDataLoaded: vi.fn(),
}),
getState: () => workflowStoreState,
}),
}))
@@ -41,6 +49,17 @@ const draftResponse = {
describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
setConversationVariables: mockSetConversationVariables,
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
}
mockFetchWorkflowDraft.mockResolvedValue(draftResponse)
})
@@ -75,6 +94,67 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
await act(async () => {
result.current.handleRefreshWorkflowDraft(true)
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
})
})
it('should cancel pending draft sync, use fallback viewport, and persist masked secrets', async () => {
workflowStoreState = {
...workflowStoreState,
debouncedSyncWorkflowDraft: { cancel: mockCancel },
}
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'server-hash',
graph: {
nodes: [{ id: 'n1' }],
edges: [],
},
environment_variables: [
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversation_variables: [{ id: 'conversation-1' }],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockCancel).toHaveBeenCalled()
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'n1' }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
'env-secret': 'top-secret',
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
])
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
})
})
it('should restore loaded state when refresh fails after workflow data was already loaded', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('refresh failed'))
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(1, false)
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(2, true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
})
})
})

View File

@@ -0,0 +1,451 @@
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks } from '../use-workflow-run-callbacks'
const {
mockSseGet,
mockResetMsgId,
} = vi.hoisted(() => ({
mockSseGet: vi.fn(),
mockResetMsgId: vi.fn(),
}))
vi.mock('@/service/base', () => ({
sseGet: mockSseGet,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
resetMsgId: mockResetMsgId,
}),
},
}))
const createHandlers = () => ({
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowPaused: vi.fn(),
})
const createUserCallbacks = () => ({
onWorkflowStarted: vi.fn(),
onWorkflowFinished: vi.fn(),
onNodeStarted: vi.fn(),
onNodeFinished: vi.fn(),
onIterationStart: vi.fn(),
onIterationNext: vi.fn(),
onIterationFinish: vi.fn(),
onLoopStart: vi.fn(),
onLoopNext: vi.fn(),
onLoopFinish: vi.fn(),
onNodeRetry: vi.fn(),
onAgentLog: vi.fn(),
onError: vi.fn(),
onWorkflowPaused: vi.fn(),
onHumanInputRequired: vi.fn(),
onHumanInputFormFilled: vi.fn(),
onHumanInputFormTimeout: vi.fn(),
onCompleted: vi.fn(),
})
describe('useWorkflowRun callbacks helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create base callbacks that wrap workflow events, errors, pause continuation, and lazy tts playback', () => {
const handlers = createHandlers()
const clearAbortController = vi.fn()
const clearListeningState = vi.fn()
const invalidateRunHistory = vi.fn()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const trackWorkflowRunFailed = vi.fn()
const userOnWorkflowFinished = vi.fn()
const userOnError = vi.fn()
const userOnWorkflowPaused = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => player)
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks: {
onWorkflowFinished: userOnWorkflowFinished,
onError: userOnError,
onWorkflowPaused: userOnWorkflowPaused,
},
restCallback: {},
getOrCreatePlayer,
})
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
expect(clearListeningState).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
expect(userOnWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userOnError).toHaveBeenCalled()
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
expect(getOrCreatePlayer).toHaveBeenCalled()
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userOnWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
})
it('should create final callbacks that preserve rest callback override order and eager abort-controller wiring', () => {
const handlers = createHandlers()
const restOnNodeStarted = vi.fn()
const setAbortController = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {
onNodeStarted: restOnNodeStarted,
},
baseSseOptions,
player,
setAbortController,
})
const controller = new AbortController()
finalCallbacks.getAbortController?.(controller)
expect(setAbortController).toHaveBeenCalledWith(controller)
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
expect(restOnNodeStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).not.toHaveBeenCalled()
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(mockResetMsgId).toHaveBeenCalledWith('message-2')
})
it('should route base workflow events through handlers, user callbacks, and pause continuation with the same callback object', async () => {
const handlers = createHandlers()
const userCallbacks = createUserCallbacks()
const clearAbortController = vi.fn()
const clearListeningState = vi.fn()
const invalidateRunHistory = vi.fn()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const trackWorkflowRunFailed = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 640,
clientHeight: 360,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks: userCallbacks,
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
callbacks.onWorkflowStarted?.({ workflow_run_id: 'run-1' } as never)
callbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
callbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
callbacks.onIterationStart?.({ node_id: 'node-1' } as never)
callbacks.onIterationNext?.({ node_id: 'node-1' } as never)
callbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
callbacks.onLoopStart?.({ node_id: 'node-1' } as never)
callbacks.onLoopNext?.({ node_id: 'node-1' } as never)
callbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
callbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
callbacks.onAgentLog?.({ node_id: 'node-1' } as never)
callbacks.onTextChunk?.({ data: 'chunk' } as never)
callbacks.onTextReplace?.({ text: 'replacement' } as never)
callbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
callbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
callbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
await callbacks.onCompleted?.(false, '')
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
callbacks.onTTSEnd?.('message-1', 'audio-finished')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never, '500')
expect(handlers.handleWorkflowStarted).toHaveBeenCalled()
expect(userCallbacks.onWorkflowStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeFinished).toHaveBeenCalled()
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationNext).toHaveBeenCalled()
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationFinished).toHaveBeenCalled()
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopNext).toHaveBeenCalled()
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopFinished).toHaveBeenCalled()
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeRetry).toHaveBeenCalled()
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
expect(handlers.handleWorkflowAgentLog).toHaveBeenCalled()
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
expect(clearListeningState).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(userCallbacks.onCompleted).toHaveBeenCalledWith(false, '')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500')
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
})
it('should skip base debug-only side effects and audio playback when debug mode is off or audio is empty', () => {
const handlers = createHandlers()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => null)
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer,
})
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
callbacks.onTTSChunk?.('message-1', '')
callbacks.onTTSEnd?.('message-1', 'audio-finished')
expect(fetchInspectVars).not.toHaveBeenCalled()
expect(invalidAllLastRun).not.toHaveBeenCalled()
expect(getOrCreatePlayer).toHaveBeenCalledTimes(1)
expect(mockResetMsgId).not.toHaveBeenCalled()
})
it('should route final workflow events through handlers and continue paused runs with final callbacks', async () => {
const handlers = createHandlers()
const userCallbacks = createUserCallbacks()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const invalidateRunHistory = vi.fn()
const setAbortController = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth: 480,
clientHeight: 320,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth: 480,
clientHeight: 320,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: userCallbacks,
restCallback: {},
baseSseOptions,
player,
setAbortController,
})
finalCallbacks.getAbortController?.(new AbortController())
finalCallbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
finalCallbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationStart?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationNext?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopStart?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopNext?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
finalCallbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
finalCallbacks.onAgentLog?.({ node_id: 'node-1' } as never)
finalCallbacks.onTextChunk?.({ data: 'chunk' } as never)
finalCallbacks.onTextReplace?.({ text: 'replacement' } as never)
finalCallbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
finalCallbacks.onTTSEnd?.('message-2', 'audio-finished')
await finalCallbacks.onCompleted?.(true, 'done')
finalCallbacks.onError?.({ error: 'failed' } as never, '500')
expect(setAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, finalCallbacks)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed' }, '500')
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
})
})

View File

@@ -0,0 +1,431 @@
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import {
applyRunningStateForMode,
applyStoppedState,
buildListeningTriggerNodeIds,
buildRunHistoryUrl,
buildTTSConfig,
buildWorkflowRunRequestBody,
clearListeningState,
clearWindowDebugControllers,
createFailedWorkflowState,
createRunningWorkflowState,
createStoppedWorkflowState,
mapPublishedWorkflowFeatures,
normalizePublishedWorkflowNodes,
resolveWorkflowRunUrl,
runTriggerDebug,
validateWorkflowRunRequest,
} from '../use-workflow-run-utils'
const {
mockPost,
mockHandleStream,
mockToastError,
} = vi.hoisted(() => ({
mockPost: vi.fn(),
mockHandleStream: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/service/base', () => ({
post: mockPost,
handleStream: mockHandleStream,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
const createListeningActions = () => ({
setWorkflowRunningData: vi.fn(),
setIsListening: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
})
describe('useWorkflowRun utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should resolve run history urls and run endpoints for workflow modes', () => {
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe('/apps/app-1/workflow-runs')
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe('/apps/app-1/advanced-chat/workflow-runs')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, true)).toBe('/apps/app-1/workflows/draft/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, TriggerType.UserInput, false)).toBe('/apps/app-1/advanced-chat/workflows/draft/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.Schedule, true)).toBe('/apps/app-1/workflows/draft/trigger/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true)).toBe('/apps/app-1/workflows/draft/trigger/run-all')
})
it('should build request bodies and validation errors for trigger runs', () => {
expect(buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' })).toEqual({ node_id: 'schedule-1' })
expect(buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' })).toEqual({ node_id: 'webhook-1' })
expect(buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' })).toEqual({ node_id: 'plugin-1' })
expect(buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual({ node_ids: ['trigger-1', 'trigger-2'] })
expect(buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } })).toEqual({ inputs: { query: 'hello' } })
expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.All)).toBe('')
expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('')
})
it('should return empty trigger urls when app id is missing and keep user-input urls empty outside workflow debug', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(resolveWorkflowRunUrl(undefined, TriggerType.Plugin, true)).toBe('')
expect(resolveWorkflowRunUrl(undefined, TriggerType.All, true)).toBe('')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, false)).toBe('')
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger plugin run')
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger run all')
consoleErrorSpy.mockRestore()
})
it('should configure listening state for trigger and non-trigger modes', () => {
const triggerActions = createListeningActions()
applyRunningStateForMode(triggerActions, TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })
expect(triggerActions.setIsListening).toHaveBeenCalledWith(true)
expect(triggerActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
expect(triggerActions.setListeningTriggerIsAll).toHaveBeenCalledWith(true)
expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
expect(triggerActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
const normalActions = createListeningActions()
applyRunningStateForMode(normalActions, TriggerType.UserInput)
expect(normalActions.setIsListening).toHaveBeenCalledWith(false)
expect(normalActions.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(normalActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(normalActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(normalActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(normalActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
})
it('should clear listening state, stop state, and remove debug controllers', () => {
const listeningActions = createListeningActions()
clearListeningState(listeningActions)
expect(listeningActions.setIsListening).toHaveBeenCalledWith(false)
expect(listeningActions.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(listeningActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(listeningActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(listeningActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
const stoppedActions = createListeningActions()
applyStoppedState(stoppedActions)
expect(stoppedActions.setWorkflowRunningData).toHaveBeenCalledWith(createStoppedWorkflowState())
expect(stoppedActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
const controllerTarget = {
__webhookDebugAbortController: { abort: vi.fn() },
__pluginDebugAbortController: { abort: vi.fn() },
__scheduleDebugAbortController: { abort: vi.fn() },
__allTriggersDebugAbortController: { abort: vi.fn() },
}
clearWindowDebugControllers(controllerTarget)
expect(controllerTarget).toEqual({})
})
it('should derive listening node ids, tts config, and published workflow mappings', () => {
expect(buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' })).toEqual(['webhook-1'])
expect(buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' })).toEqual(['schedule-1'])
expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual(['plugin-1'])
expect(buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual(['trigger-1', 'trigger-2'])
expect(buildTTSConfig({ token: 'public-token' }, '/apps/app-1')).toEqual({
ttsUrl: '/text-to-audio',
ttsIsPublic: true,
})
expect(buildTTSConfig({ appId: 'app-1' }, '/explore/installed/app-1')).toEqual({
ttsUrl: '/installed-apps/app-1/text-to-audio',
ttsIsPublic: false,
})
expect(buildTTSConfig({ appId: 'app-1' }, '/apps/app-1/workflow')).toEqual({
ttsUrl: '/apps/app-1/text-to-audio',
ttsIsPublic: false,
})
const publishedWorkflow = {
graph: {
nodes: [{ id: 'node-1', selected: true, data: { selected: true, title: 'Start' } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
file_upload: { enabled: true },
},
} as never
expect(normalizePublishedWorkflowNodes(publishedWorkflow)).toEqual([
{ id: 'node-1', selected: false, data: { selected: false, title: 'Start' } },
])
expect(mapPublishedWorkflowFeatures(publishedWorkflow)).toMatchObject({
opening: {
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
},
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
file: { enabled: true },
})
})
it('should handle trigger debug null and invalid json responses as request failures', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockResolvedValueOnce(null)
await runTriggerDebug({
debugType: TriggerType.Webhook,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'webhook-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockToastError).toHaveBeenCalledWith('Webhook debug request failed')
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(clearListeningStateSpy).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce(new Response('{invalid-json}', {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Schedule,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'schedule-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'handleRun: schedule debug response parse error',
expect.any(Error),
)
expect(mockToastError).toHaveBeenCalledWith('Schedule debug request failed')
expect(clearAbortController).toHaveBeenCalledTimes(2)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should handle trigger debug json failures and stream responses', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const baseSseOptions = {
onData: vi.fn(),
onCompleted: vi.fn(),
}
mockPost.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Webhook failed' }), {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Webhook,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'webhook-1' },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(setAbortController).toHaveBeenCalledTimes(1)
expect(mockToastError).toHaveBeenCalledWith('Webhook failed')
expect(clearAbortController).toHaveBeenCalled()
expect(clearListeningStateSpy).toHaveBeenCalled()
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Webhook failed'))
mockPost.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
expect(mockHandleStream).toHaveBeenCalledTimes(1)
})
it('should retry waiting trigger debug responses until a stream is returned', async () => {
vi.useFakeTimers()
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const baseSseOptions = {
onData: vi.fn(),
onCompleted: vi.fn(),
}
mockPost
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), {
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
const runPromise = runTriggerDebug({
debugType: TriggerType.All,
url: '/apps/app-1/workflows/draft/trigger/run-all',
requestBody: { node_ids: ['trigger-1'] },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
await vi.advanceTimersByTimeAsync(1)
await runPromise
expect(mockPost).toHaveBeenCalledTimes(2)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
expect(mockHandleStream).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('should stop trigger debug processing when the controller aborts before handling the response', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
mockPost.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController: (controller) => {
controller?.abort()
},
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockHandleStream).not.toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
expect(clearAbortController).not.toHaveBeenCalled()
expect(clearListeningStateSpy).not.toHaveBeenCalled()
expect(setWorkflowRunningData).not.toHaveBeenCalled()
})
it('should handle Response and non-Response trigger debug exceptions correctly', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
mockPost.mockRejectedValueOnce(new Response(JSON.stringify({ error: 'Plugin failed' }), {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockToastError).toHaveBeenCalledWith('Plugin failed')
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Plugin failed'))
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
mockPost.mockRejectedValueOnce(new Error('network failed'))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).toHaveBeenCalledTimes(1)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
})
it('should expose the canonical workflow state factories', () => {
expect(createRunningWorkflowState().result.status).toBe(WorkflowRunningStatus.Running)
expect(createStoppedWorkflowState().result.status).toBe(WorkflowRunningStatus.Stopped)
expect(createFailedWorkflowState('failed').result.status).toBe(WorkflowRunningStatus.Failed)
})
})

View File

@@ -0,0 +1,592 @@
import { act, renderHook } from '@testing-library/react'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useWorkflowRun } from '../use-workflow-run'
type DebugAbortControllerRef = {
abort: () => void
}
type DebugControllerWindow = Window & {
__webhookDebugAbortController?: DebugAbortControllerRef
__pluginDebugAbortController?: DebugAbortControllerRef
__scheduleDebugAbortController?: DebugAbortControllerRef
__allTriggersDebugAbortController?: DebugAbortControllerRef
}
type WorkflowStoreState = {
backupDraft?: unknown
environmentVariables?: unknown
setBackupDraft?: (value: unknown) => void
setEnvironmentVariables?: (value: unknown) => void
setWorkflowRunningData?: (value: unknown) => void
setIsListening?: (value: boolean) => void
setShowVariableInspectPanel?: (value: boolean) => void
setListeningTriggerType?: (value: unknown) => void
setListeningTriggerNodeIds?: (value: string[]) => void
setListeningTriggerIsAll?: (value: boolean) => void
setListeningTriggerNodeId?: (value: string | null) => void
}
const mocks = vi.hoisted(() => {
const appStoreState = {
appDetail: {
id: 'app-1',
mode: 'workflow',
name: 'Workflow App',
},
}
const reactFlowStoreState = {
edges: [{ id: 'edge-1' }],
getNodes: vi.fn(),
setNodes: vi.fn(),
}
const workflowStoreState: WorkflowStoreState = {}
const workflowStoreSetState = vi.fn((partial: Record<string, unknown>) => {
Object.assign(workflowStoreState, partial)
})
const featuresStoreState = {
features: {
file: {
enabled: true,
},
},
}
const featuresStoreSetState = vi.fn((partial: Record<string, unknown>) => {
Object.assign(featuresStoreState, partial)
})
return {
appStoreState,
reactFlowStoreState,
workflowStoreState,
workflowStoreSetState,
featuresStoreState,
featuresStoreSetState,
mockGetViewport: vi.fn(),
mockDoSyncWorkflowDraft: vi.fn(),
mockHandleUpdateWorkflowCanvas: vi.fn(),
mockFetchInspectVars: vi.fn(),
mockInvalidateAllLastRun: vi.fn(),
mockInvalidateRunHistory: vi.fn(),
mockSsePost: vi.fn(),
mockSseGet: vi.fn(),
mockHandleStream: vi.fn(),
mockPost: vi.fn(),
mockStopWorkflowRun: vi.fn(),
mockTrackEvent: vi.fn(),
mockGetAudioPlayer: vi.fn(),
mockResetMsgId: vi.fn(),
mockCreateBaseWorkflowRunCallbacks: vi.fn(),
mockCreateFinalWorkflowRunCallbacks: vi.fn(),
runEventHandlers: {
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowPaused: vi.fn(),
},
}
})
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => mocks.reactFlowStoreState,
}),
useReactFlow: () => ({
getViewport: mocks.mockGetViewport,
}),
}))
vi.mock('@/app/components/app/store', () => {
const useStore = Object.assign(vi.fn(), {
getState: () => mocks.appStoreState,
})
return {
useStore,
}
})
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mocks.mockTrackEvent,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
getAudioPlayer: mocks.mockGetAudioPlayer,
resetMsgId: mocks.mockResetMsgId,
}),
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => mocks.featuresStoreState,
setState: mocks.featuresStoreSetState,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
useWorkflowRunEvent: () => mocks.runEventHandlers,
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => mocks.workflowStoreState,
setState: mocks.workflowStoreSetState,
}),
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => '/apps/app-1/workflow',
}))
vi.mock('@/service/base', () => ({
ssePost: mocks.mockSsePost,
sseGet: mocks.mockSseGet,
post: mocks.mockPost,
handleStream: mocks.mockHandleStream,
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mocks.mockInvalidateAllLastRun,
useInvalidateWorkflowRunHistory: () => mocks.mockInvalidateRunHistory,
useInvalidateConversationVarValues: () => vi.fn(),
useInvalidateSysVarValues: () => vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
stopWorkflowRun: mocks.mockStopWorkflowRun,
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mocks.mockFetchInspectVars,
}),
}))
vi.mock('../use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'flow-1',
flowType: 'workflow',
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mocks.mockDoSyncWorkflowDraft,
}),
}))
vi.mock('../use-workflow-run-callbacks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../use-workflow-run-callbacks')>()
return {
...actual,
createBaseWorkflowRunCallbacks: vi.fn((params) => {
mocks.mockCreateBaseWorkflowRunCallbacks(params)
return actual.createBaseWorkflowRunCallbacks(params)
}),
createFinalWorkflowRunCallbacks: vi.fn((params) => {
mocks.mockCreateFinalWorkflowRunCallbacks(params)
return actual.createFinalWorkflowRunCallbacks(params)
}),
}
})
const createWorkflowStoreState = () => ({
backupDraft: undefined,
environmentVariables: [{ id: 'env-current', value: 'secret' }],
setBackupDraft: vi.fn((value: unknown) => {
mocks.workflowStoreState.backupDraft = value
}),
setEnvironmentVariables: vi.fn((value: unknown) => {
mocks.workflowStoreState.environmentVariables = value
}),
setWorkflowRunningData: vi.fn(),
setIsListening: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
})
describe('useWorkflowRun', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = '<div id="workflow-container"></div>'
const workflowContainer = document.getElementById('workflow-container')!
Object.defineProperty(workflowContainer, 'clientWidth', { value: 960, configurable: true })
Object.defineProperty(workflowContainer, 'clientHeight', { value: 540, configurable: true })
mocks.reactFlowStoreState.getNodes.mockReturnValue([
{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } },
])
mocks.mockGetViewport.mockReturnValue({ x: 1, y: 2, zoom: 1.5 })
mocks.mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mocks.mockPost.mockResolvedValue(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
mocks.mockGetAudioPlayer.mockReturnValue({
playAudioWithAudio: vi.fn(),
})
mocks.workflowStoreState.backupDraft = undefined
Object.assign(mocks.workflowStoreState, createWorkflowStoreState())
mocks.workflowStoreSetState.mockImplementation((partial: Record<string, unknown>) => {
Object.assign(mocks.workflowStoreState, partial)
})
mocks.featuresStoreState.features = {
file: {
enabled: true,
},
}
})
it('should backup the current draft once and skip subsequent backups until it is cleared', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleBackupDraft()
result.current.handleBackupDraft()
})
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledTimes(1)
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith({
nodes: [{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } }],
edges: [{ id: 'edge-1' }],
viewport: { x: 1, y: 2, zoom: 1.5 },
features: { file: { enabled: true } },
environmentVariables: [{ id: 'env-current', value: 'secret' }],
})
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should load a backup draft into canvas, environment variables, and features state', () => {
mocks.workflowStoreState.backupDraft = {
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 0, y: 0, zoom: 2 },
features: { opening: { enabled: true } },
environmentVariables: [{ id: 'env-backup', value: 'value' }],
}
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleLoadBackupDraft()
})
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 0, y: 0, zoom: 2 },
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-backup', value: 'value' }])
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: { opening: { enabled: true } },
})
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith(undefined)
})
it('should prepare the graph and dispatch a workflow run through ssePost for user-input mode', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
expect(mocks.reactFlowStoreState.setNodes).toHaveBeenCalledWith([
{ id: 'node-1', data: { selected: false, _runningStatus: undefined } },
])
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mocks.workflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined })
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(false)
expect(mocks.workflowStoreState.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(mocks.workflowStoreState.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
result: expect.objectContaining({
status: WorkflowRunningStatus.Running,
}),
}))
expect(mocks.mockSsePost).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/run',
{ body: { inputs: { query: 'hello' } } },
expect.objectContaining({
getAbortController: expect.any(Function),
}),
)
})
it.each([
{
title: 'schedule',
params: {},
options: { mode: TriggerType.Schedule, scheduleNodeId: 'schedule-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'schedule-1' },
expectedNodeIds: ['schedule-1'],
expectedIsAll: false,
},
{
title: 'webhook',
params: { node_id: 'webhook-1' },
options: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'webhook-1' },
expectedNodeIds: ['webhook-1'],
expectedIsAll: false,
},
{
title: 'plugin',
params: { node_id: 'plugin-1' },
options: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'plugin-1' },
expectedNodeIds: ['plugin-1'],
expectedIsAll: false,
},
{
title: 'all',
params: { node_ids: ['trigger-1', 'trigger-2'] },
options: { mode: TriggerType.All, allNodeIds: ['trigger-1', 'trigger-2'] },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run-all',
expectedBody: { node_ids: ['trigger-1', 'trigger-2'] },
expectedNodeIds: ['trigger-1', 'trigger-2'],
expectedIsAll: true,
},
])('should dispatch $title trigger runs through the debug runner integration', async ({
params,
options,
expectedUrl,
expectedBody,
expectedNodeIds,
expectedIsAll,
}) => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun(params, undefined, options)
})
expect(mocks.mockPost).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
body: expectedBody,
signal: expect.any(AbortSignal),
}),
{ needAllResponseContent: true },
)
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true)
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith(expectedNodeIds)
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll)
expect(mocks.mockSsePost).not.toHaveBeenCalled()
})
it('should expose the workflow-failed tracker through the callback factory context', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
trackWorkflowRunFailed: (params: { error?: string, node_type?: string }) => void
}
baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' })
expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', {
workflow_id: 'flow-1',
reason: 'failed',
node_type: 'llm',
})
})
it('should lazily create audio players with the correct public and private tts urls', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ token: 'public-token' })
})
const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
getOrCreatePlayer: () => unknown
}
publicBaseCallbackFactoryContext.getOrCreatePlayer()
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
'/text-to-audio',
true,
expect.any(String),
'none',
'none',
expect.any(Function),
)
mocks.mockSsePost.mockClear()
mocks.mockGetAudioPlayer.mockClear()
await act(async () => {
await result.current.handleRun({ appId: 'app-2' })
})
const privateBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
getOrCreatePlayer: () => unknown
}
privateBaseCallbackFactoryContext.getOrCreatePlayer()
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
'/apps/app-2/text-to-audio',
false,
expect.any(String),
'none',
'none',
expect.any(Function),
)
})
it('should stop workflow runs by task id or by aborting active debug controllers', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
act(() => {
result.current.handleStopRun('task-1')
})
expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/apps/app-1/workflow-runs/tasks/task-1/stop')
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
result: expect.objectContaining({
status: WorkflowRunningStatus.Stopped,
}),
}))
const webhookAbort = vi.fn()
const pluginAbort = vi.fn()
const scheduleAbort = vi.fn()
const allTriggersAbort = vi.fn()
const windowWithDebugControllers = window as DebugControllerWindow
windowWithDebugControllers.__webhookDebugAbortController = { abort: webhookAbort }
windowWithDebugControllers.__pluginDebugAbortController = { abort: pluginAbort }
windowWithDebugControllers.__scheduleDebugAbortController = { abort: scheduleAbort }
windowWithDebugControllers.__allTriggersDebugAbortController = { abort: allTriggersAbort }
const refController = new AbortController()
const refAbortSpy = vi.spyOn(refController, 'abort')
const { getAbortController } = mocks.mockSsePost.mock.calls.at(-1)?.[2] as {
getAbortController?: (controller: AbortController) => void
}
getAbortController?.(refController)
act(() => {
result.current.handleStopRun('')
})
expect(webhookAbort).toHaveBeenCalled()
expect(pluginAbort).toHaveBeenCalled()
expect(scheduleAbort).toHaveBeenCalled()
expect(allTriggersAbort).toHaveBeenCalled()
expect(refAbortSpy).toHaveBeenCalled()
})
it('should restore published workflow graph, features, and environment variables', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow({
graph: {
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
},
features: {
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-published', value: 'value' }],
} as never)
})
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }],
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: expect.objectContaining({
opening: expect.objectContaining({
enabled: true,
opening_statement: 'hello',
}),
file: { enabled: true },
}),
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }])
})
it('should restore published workflows with empty environment variables as an empty list', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow({
graph: {
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: false },
},
} as never)
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: expect.objectContaining({
opening: expect.objectContaining({ enabled: false }),
file: { enabled: false },
}),
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([])
})
})

View File

@@ -0,0 +1,391 @@
import { act, renderHook } from '@testing-library/react'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import {
BlockEnum,
WorkflowRunningStatus,
} from '@/app/components/workflow/types'
import { useWorkflowStartRun } from '../use-workflow-start-run'
const mockGetNodes = vi.fn()
const mockGetFeaturesState = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
const mockHandleRun = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockUseIsChatMode = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetShowGlobalVariablePanel = vi.fn()
const mockSetShowChatVariablePanel = vi.fn()
const mockSetListeningTriggerType = vi.fn()
const mockSetListeningTriggerNodeId = vi.fn()
const mockSetListeningTriggerNodeIds = vi.fn()
const mockSetListeningTriggerIsAll = vi.fn()
const mockSetHistoryWorkflowData = vi.fn()
let workflowStoreState: Record<string, unknown>
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: mockGetFeaturesState,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => workflowStoreState,
}),
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
useWorkflowRun: () => ({
handleRun: mockHandleRun,
}),
}))
const createWorkflowStoreState = (overrides: Record<string, unknown> = {}) => ({
workflowRunningData: undefined,
showDebugAndPreviewPanel: false,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
setShowInputsPanel: mockSetShowInputsPanel,
setShowEnvPanel: mockSetShowEnvPanel,
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
setShowChatVariablePanel: mockSetShowChatVariablePanel,
setListeningTriggerType: mockSetListeningTriggerType,
setListeningTriggerNodeId: mockSetListeningTriggerNodeId,
setListeningTriggerNodeIds: mockSetListeningTriggerNodeIds,
setListeningTriggerIsAll: mockSetListeningTriggerIsAll,
setHistoryWorkflowData: mockSetHistoryWorkflowData,
...overrides,
})
describe('useWorkflowStartRun', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = createWorkflowStoreState()
mockGetNodes.mockReturnValue([
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [] } },
])
mockGetFeaturesState.mockReturnValue({
features: {
file: {
image: {
enabled: false,
},
},
},
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockUseIsChatMode.mockReturnValue(false)
})
it('should run the workflow immediately when there are no start variables and no image upload input', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {}, files: [] })
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
})
it('should open the input panel instead of running immediately when start inputs are required', async () => {
mockGetNodes.mockReturnValue([
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [{ name: 'query' }] } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
})
it('should open the input panel when image upload is enabled even without start variables', async () => {
mockGetFeaturesState.mockReturnValue({
features: {
file: {
image: {
enabled: true,
},
},
},
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
})
it('should cancel the current debug panel instead of starting another workflow when one is already open', async () => {
workflowStoreState = createWorkflowStoreState({
showDebugAndPreviewPanel: true,
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should short-circuit workflow start when a run is already in progress', async () => {
workflowStoreState = createWorkflowStoreState({
workflowRunningData: {
result: {
status: WorkflowRunningStatus.Running,
},
},
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should configure schedule trigger runs and execute the workflow with schedule options', async () => {
mockGetNodes.mockReturnValue([
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(BlockEnum.TriggerSchedule)
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith('schedule-1')
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['schedule-1'])
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(
{},
undefined,
{
mode: TriggerType.Schedule,
scheduleNodeId: 'schedule-1',
},
)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
})
it('should cancel schedule trigger execution when the debug panel is already open', async () => {
workflowStoreState = createWorkflowStoreState({
showDebugAndPreviewPanel: true,
})
mockGetNodes.mockReturnValue([
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it.each([
{
title: 'schedule',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined),
},
{
title: 'webhook',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }),
},
{
title: 'plugin',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow(''),
},
])('should ignore $title trigger execution when the node id is empty', async ({ invoke }) => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it.each([
{
title: 'schedule',
warnMessage: 'handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'),
},
{
title: 'webhook',
warnMessage: 'handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }),
},
{
title: 'plugin',
warnMessage: 'handleWorkflowTriggerPluginRunInWorkflow: plugin node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'),
},
])('should warn when the $title trigger node cannot be found', async ({ warnMessage, invoke }) => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing'))
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it.each([
{
title: 'webhook',
nodeId: 'webhook-1',
nodeType: BlockEnum.TriggerWebhook,
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }),
expectedParams: { node_id: 'webhook-1' },
expectedOptions: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
},
{
title: 'plugin',
nodeId: 'plugin-1',
nodeType: BlockEnum.TriggerPlugin,
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'),
expectedParams: { node_id: 'plugin-1' },
expectedOptions: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
},
])('should configure $title trigger runs with node-specific options', async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => {
mockGetNodes.mockReturnValue([
{ id: nodeId, data: { type: nodeType } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType)
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId)
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId])
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions)
})
it('should run all triggers and mark the listener state as global', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowRunAllTriggersInWorkflow(['trigger-1', 'trigger-2'])
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(true)
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(
{ node_ids: ['trigger-1', 'trigger-2'] },
undefined,
{
mode: TriggerType.All,
allNodeIds: ['trigger-1', 'trigger-2'],
},
)
})
it('should ignore run-all requests when there are no trigger nodes', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowRunAllTriggersInWorkflow([])
})
expect(mockSetListeningTriggerIsAll).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should route handleStartWorkflowRun to the chatflow path when chat mode is enabled', async () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
result.current.handleStartWorkflowRun()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetHistoryWorkflowData).toHaveBeenCalledWith(undefined)
expect(mockHandleRun).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,82 @@
import { renderHook } from '@testing-library/react'
import { useWorkflowTemplate } from '../use-workflow-template'
const mockUseIsChatMode = vi.fn()
let generateNewNodeCalls: Array<Record<string, unknown>> = []
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
generateNewNode: (args: { id?: string, data: Record<string, unknown>, position: Record<string, unknown> }) => {
generateNewNodeCalls.push(args)
return {
newNode: {
id: args.id ?? `generated-${generateNewNodeCalls.length}`,
data: args.data,
position: args.position,
},
}
},
}
})
describe('useWorkflowTemplate', () => {
beforeEach(() => {
vi.clearAllMocks()
generateNewNodeCalls = []
})
it('should return only the start node template in workflow mode', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useWorkflowTemplate())
expect(result.current.nodes).toHaveLength(1)
expect(result.current.edges).toEqual([])
expect(generateNewNodeCalls).toHaveLength(1)
})
it('should build start, llm, and answer templates with linked edges in chat mode', () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useWorkflowTemplate())
expect(result.current.nodes).toHaveLength(3)
expect(result.current.nodes.map(node => node.id)).toEqual(['generated-1', 'llm', 'answer'])
expect(result.current.edges).toEqual([
{
id: 'generated-1-llm',
source: 'generated-1',
sourceHandle: 'source',
target: 'llm',
targetHandle: 'target',
},
{
id: 'llm-answer',
source: 'llm',
sourceHandle: 'source',
target: 'answer',
targetHandle: 'target',
},
])
expect(generateNewNodeCalls).toHaveLength(3)
expect(generateNewNodeCalls[0].data).toMatchObject({
type: 'start',
title: 'workflow.blocks.start',
})
expect(generateNewNodeCalls[1].data).toMatchObject({
type: 'llm',
title: 'workflow.blocks.llm',
})
expect(generateNewNodeCalls[2].data).toMatchObject({
type: 'answer',
title: 'workflow.blocks.answer',
answer: '{{#llm.text#}}',
})
})
})

View File

@@ -0,0 +1,470 @@
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { IOtherOptions } from '@/service/base'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { sseGet } from '@/service/base'
type ContainerSize = {
clientWidth: number
clientHeight: number
}
type WorkflowRunEventHandlers = {
handleWorkflowStarted: NonNullable<IOtherOptions['onWorkflowStarted']>
handleWorkflowFinished: NonNullable<IOtherOptions['onWorkflowFinished']>
handleWorkflowFailed: () => void
handleWorkflowNodeStarted: (params: Parameters<NonNullable<IOtherOptions['onNodeStarted']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeFinished: NonNullable<IOtherOptions['onNodeFinished']>
handleWorkflowNodeHumanInputRequired: NonNullable<IOtherOptions['onHumanInputRequired']>
handleWorkflowNodeHumanInputFormFilled: NonNullable<IOtherOptions['onHumanInputFormFilled']>
handleWorkflowNodeHumanInputFormTimeout: NonNullable<IOtherOptions['onHumanInputFormTimeout']>
handleWorkflowNodeIterationStarted: (params: Parameters<NonNullable<IOtherOptions['onIterationStart']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeIterationNext: NonNullable<IOtherOptions['onIterationNext']>
handleWorkflowNodeIterationFinished: NonNullable<IOtherOptions['onIterationFinish']>
handleWorkflowNodeLoopStarted: (params: Parameters<NonNullable<IOtherOptions['onLoopStart']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeLoopNext: NonNullable<IOtherOptions['onLoopNext']>
handleWorkflowNodeLoopFinished: NonNullable<IOtherOptions['onLoopFinish']>
handleWorkflowNodeRetry: NonNullable<IOtherOptions['onNodeRetry']>
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
handleWorkflowPaused: () => void
}
type UserCallbackHandlers = {
onWorkflowStarted?: IOtherOptions['onWorkflowStarted']
onWorkflowFinished?: IOtherOptions['onWorkflowFinished']
onNodeStarted?: IOtherOptions['onNodeStarted']
onNodeFinished?: IOtherOptions['onNodeFinished']
onIterationStart?: IOtherOptions['onIterationStart']
onIterationNext?: IOtherOptions['onIterationNext']
onIterationFinish?: IOtherOptions['onIterationFinish']
onLoopStart?: IOtherOptions['onLoopStart']
onLoopNext?: IOtherOptions['onLoopNext']
onLoopFinish?: IOtherOptions['onLoopFinish']
onNodeRetry?: IOtherOptions['onNodeRetry']
onAgentLog?: IOtherOptions['onAgentLog']
onError?: IOtherOptions['onError']
onWorkflowPaused?: IOtherOptions['onWorkflowPaused']
onHumanInputRequired?: IOtherOptions['onHumanInputRequired']
onHumanInputFormFilled?: IOtherOptions['onHumanInputFormFilled']
onHumanInputFormTimeout?: IOtherOptions['onHumanInputFormTimeout']
onCompleted?: IOtherOptions['onCompleted']
}
type CallbackContext = {
clientWidth: number
clientHeight: number
runHistoryUrl: string
isInWorkflowDebug: boolean
fetchInspectVars: (params: Record<string, never>) => void
invalidAllLastRun: () => void
invalidateRunHistory: (url: string) => void
clearAbortController: () => void
clearListeningState: () => void
trackWorkflowRunFailed: (params: unknown) => void
handlers: WorkflowRunEventHandlers
callbacks: UserCallbackHandlers
restCallback: IOtherOptions
}
type BaseCallbacksContext = CallbackContext & {
getOrCreatePlayer: () => AudioPlayer | null
}
type FinalCallbacksContext = CallbackContext & {
baseSseOptions: IOtherOptions
player: AudioPlayer | null
setAbortController: (controller: AbortController) => void
}
export const createBaseWorkflowRunCallbacks = ({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks,
restCallback,
getOrCreatePlayer,
}: BaseCallbacksContext): IOtherOptions => {
const {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
} = handlers
const {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onCompleted,
} = callbacks
const wrappedOnError: IOtherOptions['onError'] = (params, code) => {
clearAbortController()
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
clearListeningState()
if (onError)
onError(params, code)
trackWorkflowRunFailed(params)
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError, errorMessage) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (_messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(url, {}, baseSseOptions)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
return baseSseOptions
}
export const createFinalWorkflowRunCallbacks = ({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController: _clearAbortController,
clearListeningState: _clearListeningState,
trackWorkflowRunFailed: _trackWorkflowRunFailed,
handlers,
callbacks,
restCallback,
baseSseOptions,
player,
setAbortController,
}: FinalCallbacksContext): IOtherOptions => {
const {
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
} = handlers
const {
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
} = callbacks
const finalCallbacks: IOtherOptions = {
...baseSseOptions,
getAbortController: (controller: AbortController) => {
setAbortController(controller)
},
onWorkflowFinished: (params) => {
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onError: (params, code) => {
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
if (onError)
onError(params, code)
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player?.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (_messageId: string, audio: string) => {
player?.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(url, {}, finalCallbacks)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
...restCallback,
}
return finalCallbacks
}

View File

@@ -0,0 +1,443 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { TriggerNodeType } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { toast } from '@/app/components/base/ui/toast'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { handleStream, post } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { AppModeEnum } from '@/types/app'
export type HandleRunMode = TriggerType
export type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
export type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
type AppDetailLike = {
id?: string
mode?: AppModeEnum
}
type TTSParamsLike = {
token?: string
appId?: string
}
type ListeningStateActions = {
setWorkflowRunningData: (data: ReturnType<typeof createRunningWorkflowState> | ReturnType<typeof createFailedWorkflowState> | ReturnType<typeof createStoppedWorkflowState>) => void
setIsListening: (value: boolean) => void
setShowVariableInspectPanel: (value: boolean) => void
setListeningTriggerType: (value: TriggerNodeType | null) => void
setListeningTriggerNodeIds: (value: string[]) => void
setListeningTriggerIsAll: (value: boolean) => void
setListeningTriggerNodeId: (value: string | null) => void
}
type TriggerDebugRunnerOptions = {
debugType: DebuggableTriggerType
url: string
requestBody: unknown
baseSseOptions: IOtherOptions
controllerTarget: Record<string, unknown>
setAbortController: (controller: AbortController | null) => void
clearAbortController: () => void
clearListeningState: () => void
setWorkflowRunningData: ListeningStateActions['setWorkflowRunningData']
}
export const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
export const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
export const createRunningWorkflowState = () => {
return {
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
}
}
export const createStoppedWorkflowState = () => {
return {
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
}
}
export const createFailedWorkflowState = (error: string) => {
return {
result: {
status: WorkflowRunningStatus.Failed,
error,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
}
}
export const buildRunHistoryUrl = (appDetail?: AppDetailLike) => {
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
: `/apps/${appDetail?.id}/workflow-runs`
}
export const resolveWorkflowRunUrl = (
appDetail: AppDetailLike | undefined,
runMode: HandleRunMode,
isInWorkflowDebug: boolean,
) => {
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return ''
}
return `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return ''
}
return `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT)
return `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
if (isInWorkflowDebug && appDetail?.id)
return `/apps/${appDetail.id}/workflows/draft/run`
return ''
}
export const buildWorkflowRunRequestBody = (
runMode: HandleRunMode,
resolvedParams: Record<string, unknown>,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.Schedule)
return { node_id: options?.scheduleNodeId }
if (runMode === TriggerType.Webhook)
return { node_id: options?.webhookNodeId }
if (runMode === TriggerType.Plugin)
return { node_id: options?.pluginNodeId }
if (runMode === TriggerType.All)
return { node_ids: options?.allNodeIds }
return resolvedParams
}
export const validateWorkflowRunRequest = (
runMode: HandleRunMode,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId)
return 'handleRun: schedule trigger run requires node id'
if (runMode === TriggerType.Webhook && !options?.webhookNodeId)
return 'handleRun: webhook trigger run requires node id'
if (runMode === TriggerType.Plugin && !options?.pluginNodeId)
return 'handleRun: plugin trigger run requires node id'
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0)
return 'handleRun: all trigger run requires node ids'
return ''
}
export const isDebuggableTriggerType = (
runMode: HandleRunMode,
): runMode is DebuggableTriggerType => {
return (
runMode === TriggerType.Schedule
|| runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
)
}
export const buildListeningTriggerNodeIds = (
runMode: DebuggableTriggerType,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.All)
return options?.allNodeIds ?? []
if (runMode === TriggerType.Webhook && options?.webhookNodeId)
return [options.webhookNodeId]
if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
return [options.scheduleNodeId]
if (runMode === TriggerType.Plugin && options?.pluginNodeId)
return [options.pluginNodeId]
return []
}
export const applyRunningStateForMode = (
actions: ListeningStateActions,
runMode: HandleRunMode,
options?: HandleRunOptions,
) => {
if (isDebuggableTriggerType(runMode)) {
actions.setIsListening(true)
actions.setShowVariableInspectPanel(true)
actions.setListeningTriggerIsAll(runMode === TriggerType.All)
actions.setListeningTriggerNodeIds(buildListeningTriggerNodeIds(runMode, options))
actions.setWorkflowRunningData(createRunningWorkflowState())
return
}
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setListeningTriggerNodeIds([])
actions.setListeningTriggerIsAll(false)
actions.setWorkflowRunningData(createRunningWorkflowState())
}
export const clearListeningState = (actions: Pick<ListeningStateActions, 'setIsListening' | 'setListeningTriggerType' | 'setListeningTriggerNodeId' | 'setListeningTriggerNodeIds' | 'setListeningTriggerIsAll'>) => {
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setListeningTriggerNodeIds([])
actions.setListeningTriggerIsAll(false)
}
export const applyStoppedState = (actions: Pick<ListeningStateActions, 'setWorkflowRunningData' | 'setIsListening' | 'setShowVariableInspectPanel' | 'setListeningTriggerType' | 'setListeningTriggerNodeId'>) => {
actions.setWorkflowRunningData(createStoppedWorkflowState())
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setShowVariableInspectPanel(true)
}
export const clearWindowDebugControllers = (controllerTarget: Record<string, unknown>) => {
delete controllerTarget.__webhookDebugAbortController
delete controllerTarget.__pluginDebugAbortController
delete controllerTarget.__scheduleDebugAbortController
delete controllerTarget.__allTriggersDebugAbortController
}
export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) => {
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
return {
ttsUrl,
ttsIsPublic,
}
}
export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): FeaturesData => {
return {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
}
export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistory) => {
return publishedWorkflow.graph.nodes.map(node => ({
...node,
selected: false,
data: {
...node.data,
selected: false,
},
}))
}
export const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
export const runTriggerDebug = async ({
debugType,
url,
requestBody,
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState,
setWorkflowRunningData,
}: TriggerDebugRunnerOptions) => {
const controller = new AbortController()
setAbortController(controller)
const controllerKey = controllerKeyMap[debugType]
controllerTarget[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
toast.error(message)
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: Record<string, unknown> | null = null
try {
data = await response.json() as Record<string, unknown>
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
toast.error(`${debugLabel} debug request failed`)
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed`
toast.error(errorMessage)
clearAbortController()
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onHumanInputRequired,
baseSseOptions.onHumanInputFormFilled,
baseSseOptions.onHumanInputFormTimeout,
baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, unknown>
const errorMessage = typeof data?.error === 'string' ? data.error : ''
toast.error(errorMessage)
clearAbortController()
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
}
clearListeningState()
}
}
await poll()
}

View File

@@ -1,3 +1,4 @@
import type { HandleRunOptions } from './use-workflow-run-utils'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { Node } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
@@ -14,46 +15,38 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Toast from '@/app/components/base/toast'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { usePathname } from '@/next/navigation'
import { handleStream, post, sseGet, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { ssePost } from '@/service/base'
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
import { useConfigsMap } from './use-configs-map'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import {
createBaseWorkflowRunCallbacks,
createFinalWorkflowRunCallbacks,
} from './use-workflow-run-callbacks'
import {
applyRunningStateForMode,
applyStoppedState,
buildRunHistoryUrl,
buildTTSConfig,
buildWorkflowRunRequestBody,
clearListeningState,
clearWindowDebugControllers,
type HandleRunMode = TriggerType
type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
isDebuggableTriggerType,
mapPublishedWorkflowFeatures,
normalizePublishedWorkflowNodes,
resolveWorkflowRunUrl,
runTriggerDebug,
validateWorkflowRunRequest,
} from './use-workflow-run-utils'
export const useWorkflowRun = () => {
const store = useStoreApi()
@@ -152,7 +145,7 @@ export const useWorkflowRun = () => {
callback?: IOtherOptions,
options?: HandleRunOptions,
) => {
const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput
const runMode = options?.mode ?? TriggerType.UserInput
const resolvedParams = params ?? {}
const {
getNodes,
@@ -190,9 +183,7 @@ export const useWorkflowRun = () => {
} = callback || {}
workflowStore.setState({ historyWorkflowData: undefined })
const appDetail = useAppStore.getState().appDetail
const runHistoryUrl = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
: `/apps/${appDetail?.id}/workflow-runs`
const runHistoryUrl = buildRunHistoryUrl(appDetail)
const workflowContainer = document.getElementById('workflow-container')
const {
@@ -202,65 +193,15 @@ export const useWorkflowRun = () => {
const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW
let url = ''
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
else if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
}
else if (isInWorkflowDebug && appDetail?.id) {
url = `/apps/${appDetail.id}/workflows/draft/run`
}
let requestBody = {}
if (runMode === TriggerType.Schedule)
requestBody = { node_id: options?.scheduleNodeId }
else if (runMode === TriggerType.Webhook)
requestBody = { node_id: options?.webhookNodeId }
else if (runMode === TriggerType.Plugin)
requestBody = { node_id: options?.pluginNodeId }
else if (runMode === TriggerType.All)
requestBody = { node_ids: options?.allNodeIds }
else
requestBody = resolvedParams
const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug)
const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options)
if (!url)
return
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) {
console.error('handleRun: schedule trigger run requires node id')
return
}
if (runMode === TriggerType.Webhook && !options?.webhookNodeId) {
console.error('handleRun: webhook trigger run requires node id')
return
}
if (runMode === TriggerType.Plugin && !options?.pluginNodeId) {
console.error('handleRun: plugin trigger run requires node id')
return
}
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) {
console.error('handleRun: all trigger run requires node ids')
const validationMessage = validateWorkflowRunRequest(runMode, options)
if (validationMessage) {
console.error(validationMessage)
return
}
@@ -277,66 +218,17 @@ export const useWorkflowRun = () => {
setListeningTriggerNodeId,
} = workflowStore.getState()
if (
runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
|| runMode === TriggerType.Schedule
) {
setIsListening(true)
setShowVariableInspectPanel(true)
setListeningTriggerIsAll(runMode === TriggerType.All)
if (runMode === TriggerType.All)
setListeningTriggerNodeIds(options?.allNodeIds ?? [])
else if (runMode === TriggerType.Webhook && options?.webhookNodeId)
setListeningTriggerNodeIds([options.webhookNodeId])
else if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
setListeningTriggerNodeIds([options.scheduleNodeId])
else if (runMode === TriggerType.Plugin && options?.pluginNodeId)
setListeningTriggerNodeIds([options.pluginNodeId])
else
setListeningTriggerNodeIds([])
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
else {
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setListeningTriggerNodeIds([])
setListeningTriggerIsAll(false)
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
applyRunningStateForMode({
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
setListeningTriggerNodeId,
}, runMode, options)
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname)
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
// This prevents opening audio channel unnecessarily
let player: AudioPlayer | null = null
@@ -349,497 +241,121 @@ export const useWorkflowRun = () => {
const clearAbortController = () => {
abortControllerRef.current = null
delete (window as any).__webhookDebugAbortController
delete (window as any).__pluginDebugAbortController
delete (window as any).__scheduleDebugAbortController
delete (window as any).__allTriggersDebugAbortController
clearWindowDebugControllers(window as unknown as Record<string, unknown>)
}
const clearListeningState = () => {
const clearListeningStateInStore = () => {
const state = workflowStore.getState()
state.setIsListening(false)
state.setListeningTriggerType(null)
state.setListeningTriggerNodeId(null)
state.setListeningTriggerNodeIds([])
state.setListeningTriggerIsAll(false)
clearListeningState({
setIsListening: state.setIsListening,
setListeningTriggerType: state.setListeningTriggerType,
setListeningTriggerNodeId: state.setListeningTriggerNodeId,
setListeningTriggerNodeIds: state.setListeningTriggerNodeIds,
setListeningTriggerIsAll: state.setListeningTriggerIsAll,
})
}
const wrappedOnError = (params: any) => {
clearAbortController()
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
clearListeningState()
if (onError)
onError(params)
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: params.error, node_type: params.node_type })
const workflowRunEventHandlers = {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
}
const userCallbacks = {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onCompleted,
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
const trackWorkflowRunFailed = (eventParams: unknown) => {
const payload = eventParams as { error?: string, node_type?: string }
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: payload?.error, node_type: payload?.node_type })
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(
url,
{},
baseSseOptions,
)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState: clearListeningStateInStore,
trackWorkflowRunFailed,
handlers: workflowRunEventHandlers,
callbacks: userCallbacks,
restCallback,
getOrCreatePlayer,
})
const runTriggerDebug = async (debugType: DebuggableTriggerType) => {
const controller = new AbortController()
abortControllerRef.current = controller
const controllerKey = controllerKeyMap[debugType]
; (window as any)[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
Toast.notify({ type: 'error', message })
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: any = null
try {
data = await response.json()
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` })
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = data?.message || `${debugLabel} debug failed`
Toast.notify({ type: 'error', message: errorMessage })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: errorMessage,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onHumanInputRequired,
baseSseOptions.onHumanInputFormFilled,
baseSseOptions.onHumanInputFormTimeout,
baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, any>
const { error: respError } = data || {}
Toast.notify({ type: 'error', message: respError })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: respError,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
}
clearListeningState()
}
}
await poll()
}
if (runMode === TriggerType.Schedule) {
await runTriggerDebug(TriggerType.Schedule)
if (isDebuggableTriggerType(runMode)) {
await runTriggerDebug({
debugType: runMode,
url,
requestBody,
baseSseOptions,
controllerTarget: window as unknown as Record<string, unknown>,
setAbortController: (controller) => {
abortControllerRef.current = controller
},
clearAbortController,
clearListeningState: clearListeningStateInStore,
setWorkflowRunningData,
})
return
}
if (runMode === TriggerType.Webhook) {
await runTriggerDebug(TriggerType.Webhook)
return
}
if (runMode === TriggerType.Plugin) {
await runTriggerDebug(TriggerType.Plugin)
return
}
if (runMode === TriggerType.All) {
await runTriggerDebug(TriggerType.All)
return
}
const finalCallbacks: IOtherOptions = {
...baseSseOptions,
getAbortController: (controller: AbortController) => {
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState: clearListeningStateInStore,
trackWorkflowRunFailed,
handlers: workflowRunEventHandlers,
callbacks: userCallbacks,
restCallback,
baseSseOptions,
player,
setAbortController: (controller) => {
abortControllerRef.current = controller
},
onWorkflowFinished: (params) => {
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onError: (params) => {
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
if (onError)
onError(params)
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player?.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (messageId: string, audio: string) => {
player?.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(
url,
{},
finalCallbacks,
)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
...restCallback,
}
})
ssePost(
url,
@@ -860,20 +376,13 @@ export const useWorkflowRun = () => {
setListeningTriggerNodeId,
} = workflowStore.getState()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
applyStoppedState({
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeId,
})
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setShowVariableInspectPanel(true)
}
if (taskId) {
@@ -909,7 +418,7 @@ export const useWorkflowRun = () => {
}, [workflowStore])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
const nodes = normalizePublishedWorkflowNodes(publishedWorkflow)
const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!
handleUpdateWorkflowCanvas({
@@ -917,21 +426,7 @@ export const useWorkflowRun = () => {
edges,
viewport,
})
const mappedFeatures = {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
featuresStore?.setState({ features: mappedFeatures })
featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) })
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])

View File

@@ -9,16 +9,12 @@ import {
import { useStore as useAppStore } from '@/app/components/app/store'
import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import {
WorkflowContextProvider,
} from '@/app/components/workflow/context'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import {
SupportUploadFileTypes,
} from '@/app/components/workflow/types'
import {
initialEdges,
initialNodes,
@@ -35,6 +31,11 @@ import {
useWorkflowInit,
} from './hooks/use-workflow-init'
import { createWorkflowSlice } from './store/workflow/workflow-slice'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from './utils'
const WorkflowAppWithAdditionalContext = () => {
const {
@@ -58,13 +59,7 @@ const WorkflowAppWithAdditionalContext = () => {
// Sync trigger statuses to store when data loads
useEffect(() => {
if (triggersResponse?.data) {
// Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled'
const statusMap = triggersResponse.data.reduce((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {} as Record<string, 'enabled' | 'disabled'>)
setTriggerStatuses(statusMap)
setTriggerStatuses(buildTriggerStatusMap(triggersResponse.data))
}
}, [triggersResponse?.data, setTriggerStatuses])
@@ -108,49 +103,21 @@ const WorkflowAppWithAdditionalContext = () => {
fetchRunDetail(runUrl).then((res) => {
const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState()
const rawInputs = res.inputs
let parsedInputs: Record<string, unknown> | null = null
let parsedInputs: unknown = rawInputs
if (typeof rawInputs === 'string') {
try {
const maybeParsed = JSON.parse(rawInputs) as unknown
if (maybeParsed && typeof maybeParsed === 'object' && !Array.isArray(maybeParsed))
parsedInputs = maybeParsed as Record<string, unknown>
parsedInputs = JSON.parse(rawInputs) as unknown
}
catch (error) {
console.error('Failed to parse workflow run inputs', error)
return
}
}
else if (rawInputs && typeof rawInputs === 'object' && !Array.isArray(rawInputs)) {
parsedInputs = rawInputs as Record<string, unknown>
}
if (!parsedInputs)
return
const userInputs = coerceReplayUserInputs(parsedInputs)
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(parsedInputs).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
if (!Object.keys(userInputs).length)
if (!userInputs || !Object.keys(userInputs).length)
return
setInputs(userInputs)
@@ -167,32 +134,7 @@ const WorkflowAppWithAdditionalContext = () => {
)
}
const features = data.features || {}
const initialFeatures: FeaturesData = {
file: {
image: {
enabled: !!features.file_upload?.image?.enabled,
number_limits: features.file_upload?.image?.number_limits || 3,
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
const initialFeatures: FeaturesData = buildInitialFeatures(data.features, fileUploadConfigResponse)
return (
<WorkflowWithDefaultContext

View File

@@ -0,0 +1,44 @@
import { createStore } from 'zustand/vanilla'
import { createWorkflowSlice } from '../workflow-slice'
describe('createWorkflowSlice', () => {
it('should initialize workflow slice state with expected defaults', () => {
const store = createStore(createWorkflowSlice)
const state = store.getState()
expect(state.appId).toBe('')
expect(state.appName).toBe('')
expect(state.notInitialWorkflow).toBe(false)
expect(state.shouldAutoOpenStartNodeSelector).toBe(false)
expect(state.nodesDefaultConfigs).toEqual({})
expect(state.showOnboarding).toBe(false)
expect(state.hasSelectedStartNode).toBe(false)
expect(state.hasShownOnboarding).toBe(false)
})
it('should update every workflow slice field through its setters', () => {
const store = createStore(createWorkflowSlice)
store.setState({
appId: 'app-1',
appName: 'Workflow App',
})
store.getState().setNotInitialWorkflow(true)
store.getState().setShouldAutoOpenStartNodeSelector(true)
store.getState().setNodesDefaultConfigs({ start: { title: 'Start' } })
store.getState().setShowOnboarding(true)
store.getState().setHasSelectedStartNode(true)
store.getState().setHasShownOnboarding(true)
expect(store.getState()).toMatchObject({
appId: 'app-1',
appName: 'Workflow App',
notInitialWorkflow: true,
shouldAutoOpenStartNodeSelector: true,
nodesDefaultConfigs: { start: { title: 'Start' } },
showOnboarding: true,
hasSelectedStartNode: true,
hasShownOnboarding: true,
})
})
})

View File

@@ -0,0 +1,107 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
type TriggerStatusLike = {
node_id: string
status: string
}
type FileUploadFeatureLike = {
enabled?: boolean
allowed_file_types?: SupportUploadFileTypes[]
allowed_file_extensions?: string[]
allowed_file_upload_methods?: TransferMethod[]
number_limits?: number
image?: {
enabled?: boolean
number_limits?: number
transfer_methods?: TransferMethod[]
}
}
type WorkflowFeaturesLike = {
file_upload?: FileUploadFeatureLike
opening_statement?: string
suggested_questions?: string[]
suggested_questions_after_answer?: { enabled?: boolean }
speech_to_text?: { enabled?: boolean }
text_to_speech?: { enabled?: boolean }
retriever_resource?: { enabled?: boolean }
sensitive_word_avoidance?: { enabled?: boolean }
}
export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => {
return triggers.reduce<Record<string, 'enabled' | 'disabled'>>((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {})
}
export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, string | number | boolean> | null => {
if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs))
return null
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(rawInputs as Record<string, unknown>).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
return userInputs
}
export const buildInitialFeatures = (
featuresSource: WorkflowFeaturesLike | null | undefined,
fileUploadConfigResponse: FileUploadConfigResponse | undefined,
): FeaturesData => {
const features = featuresSource || {}
const fileUpload = features.file_upload
const imageUpload = fileUpload?.image
return {
file: {
image: {
enabled: !!imageUpload?.enabled,
number_limits: imageUpload?.number_limits || 3,
transfer_methods: imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
},
enabled: !!(fileUpload?.enabled || imageUpload?.enabled),
allowed_file_types: fileUpload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: fileUpload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: fileUpload?.number_limits || imageUpload?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
}

View File

@@ -2,6 +2,9 @@ import { renderHook } from '@testing-library/react'
import useNodeResizeObserver from '../use-node-resize-observer'
describe('useNodeResizeObserver', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('should observe and disconnect when enabled with a mounted node ref', () => {
const observe = vi.fn()
const disconnect = vi.fn()

View File

@@ -57,6 +57,16 @@ describe('before-run-form helpers', () => {
values: createValues({ query: '' }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile, required: true })],
values: createValues({ file: [] }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles, required: true })],
values: createValues({ files: [] }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile })],
values: createValues({ file: { transferMethod: TransferMethod.local_file } }),

View File

@@ -56,7 +56,16 @@ export const getFormErrorMessage = (
const missingRequired = input.required
&& input.type !== InputVarType.checkbox
&& !(input.variable in existVarValuesInForm)
&& (value === '' || value === undefined || value === null || (input.type === InputVarType.files && Array.isArray(value) && value.length === 0))
&& (
value === '' || value === undefined || value === null
|| (
(input.type === InputVarType.files
|| input.type === InputVarType.multiFiles
|| input.type === InputVarType.singleFile)
&& Array.isArray(value)
&& value.length === 0
)
)
if (!errMsg && missingRequired) {
errMsg = t('errorMsg.fieldRequired', { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label })

View File

@@ -75,16 +75,12 @@ describe('workflow-panel helpers', () => {
})
describe('custom run form fallback', () => {
it('should return a fallback message for unsupported custom run form nodes', () => {
it('should return null for unsupported custom run form nodes', () => {
const form = getCustomRunForm({
...createCustomRunFormProps({ type: BlockEnum.Tool }),
})
expect(form).toMatchObject({
props: {
children: expect.arrayContaining(['Custom Run Form:', ' ', 'not found']),
},
})
expect(form).toBeNull()
})
})
})

View File

@@ -39,14 +39,7 @@ export const getCustomRunForm = (params: CustomRunFormProps): ReactNode => {
case BlockEnum.DataSource:
return <DataSourceBeforeRunForm {...params} />
default:
return (
<div>
Custom Run Form:
{nodeType}
{' '}
not found
</div>
)
return null
}
}

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import GenericTable from '../generic-table'
@@ -50,8 +50,19 @@ const advancedColumns = [
describe('GenericTable', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
const selectOption = async (triggerName: string, optionName: string) => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: triggerName }))
})
await act(async () => {
fireEvent.click(await screen.findByRole('option', { name: optionName }))
})
}
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
const onChange = vi.fn()
@@ -143,11 +154,11 @@ describe('GenericTable', () => {
<ControlledTable />,
)
await user.click(screen.getByRole('button', { name: 'Choose method' }))
await user.click(await screen.findByRole('option', { name: 'POST' }))
await selectOption('Choose method', 'POST')
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
expect(screen.getByRole('button', { name: 'POST' })).toBeInTheDocument()
})
onChange.mockClear()

View File

@@ -90,6 +90,22 @@ describe('useVariableModalState', () => {
])
})
it('should keep valid object rows when switching to json mode from form mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.Object)
result.current.setObjectValue([
{ key: '', type: ChatVarType.String, value: undefined },
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual({ timeout: 30 })
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
})
it('should reset object form values when leaving empty json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
@@ -141,6 +157,19 @@ describe('useVariableModalState', () => {
expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False']))
})
it('should preserve zero values when switching number arrays into json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayNumber)
result.current.setValue([0, 2, undefined])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual([0, 2])
expect(result.current.editorContent).toBe(JSON.stringify([0, 2]))
})
it('should notify and stop saving when object keys are invalid', () => {
const notify = vi.fn()
const onSave = vi.fn()
@@ -161,7 +190,7 @@ describe('useVariableModalState', () => {
result.current.handleSave()
})
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'object key can not be empty' })
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' })
expect(onSave).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})

View File

@@ -33,6 +33,10 @@ describe('variable-modal helpers', () => {
{ key: '', type: ChatVarType.Number, value: 1 },
])).toEqual({ apiKey: 'secret' })
expect(formatObjectValueFromList([
{ key: 'count', type: ChatVarType.Number, value: 0 },
{ key: 'label', type: ChatVarType.String, value: '' },
])).toEqual({ count: 0, label: null })
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }],
@@ -54,6 +58,13 @@ describe('variable-modal helpers', () => {
value: ['a', '', 'b'],
})).toEqual(['a', 'b'])
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.ArrayNumber,
value: [0, 1, undefined, null, ''] as unknown as Array<number | undefined>,
})).toEqual([0, 1])
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
@@ -94,6 +105,10 @@ describe('variable-modal helpers', () => {
type: ChatVarType.ArrayBoolean,
})).toEqual([true, false, true, false])
expect(() => parseEditorContent({
content: '{"enabled":true}',
type: ChatVarType.ArrayBoolean,
})).toThrow('JSON array')
expect(parseEditorContent({
content: '{"enabled":true}',
type: ChatVarType.Object,

View File

@@ -80,7 +80,7 @@ describe('variable-modal', () => {
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name')
await user.click(screen.getByText('common.operation.save'))
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('name is existed')
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}')
expect(onSave).not.toHaveBeenCalled()
})
@@ -100,8 +100,10 @@ describe('variable-modal', () => {
expect(screen.getByDisplayValue('secret')).toBeInTheDocument()
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
const timeoutInput = screen.getByDisplayValue('30') as HTMLInputElement
await user.clear(screen.getByDisplayValue('secret'))
await user.type(screen.getByDisplayValue('30'), '5')
await user.clear(timeoutInput)
await user.type(timeoutInput, '5')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
@@ -110,7 +112,7 @@ describe('variable-modal', () => {
value_type: ChatVarType.Object,
value: {
apiKey: null,
timeout: 305,
timeout: 5,
},
description: 'settings',
})
@@ -195,4 +197,22 @@ describe('variable-modal', () => {
description: '',
})
})
it('should keep the number input empty while editing after the user clears it', async () => {
const user = userEvent.setup()
renderVariableModal({
chatVar: {
id: 'var-4',
name: 'timeout',
description: '',
value_type: ChatVarType.Number,
value: 3,
},
})
const input = screen.getByDisplayValue('3') as HTMLInputElement
await user.clear(input)
expect(input.value).toBe('')
})
})

View File

@@ -108,7 +108,7 @@ export const useVariableModalState = ({
if (prev.type === ChatVarType.Object) {
if (nextEditInJSON) {
const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue)
const nextValue = prev.objectValue.some(item => item.key) ? formatObjectValueFromList(prev.objectValue) : undefined
nextState.value = nextValue
nextState.editorContent = JSON.stringify(nextValue)
return nextState
@@ -133,8 +133,11 @@ export const useVariableModalState = ({
if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) {
if (nextEditInJSON) {
const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length)
? prev.value.filter(Boolean)
const compactValues = Array.isArray(prev.value)
? prev.value.filter(item => item !== null && item !== undefined && item !== '')
: []
const nextValue = compactValues.length
? compactValues
: undefined
nextState.value = nextValue
if (!prev.editorContent)
@@ -181,12 +184,15 @@ export const useVariableModalState = ({
return
if (!chatVar && conversationVariables.some(item => item.name === state.name)) {
notify({ type: 'error', message: 'name is existed' })
notify({
type: 'error',
message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }),
})
return
}
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) {
notify({ type: 'error', message: 'object key can not be empty' })
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && item.value !== undefined && item.value !== '')) {
notify({ type: 'error', message: t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }) })
return
}

View File

@@ -72,7 +72,7 @@ export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectVal
export const formatObjectValueFromList = (list: ObjectValueItem[]) => {
return list.reduce<Record<string, string | number | null>>((acc, curr) => {
if (curr.key)
acc[curr.key] = curr.value || null
acc[curr.key] = curr.value === '' || curr.value === undefined ? null : curr.value
return acc
}, {})
}
@@ -88,6 +88,8 @@ export const formatChatVariableValue = ({
type: ChatVarType
value: unknown
}) => {
const compactArrayValue = (items: unknown[]) =>
items.filter(item => item !== null && item !== undefined && item !== '')
switch (type) {
case ChatVarTypeEnum.String:
return value || ''
@@ -100,7 +102,7 @@ export const formatChatVariableValue = ({
case ChatVarTypeEnum.ArrayString:
case ChatVarTypeEnum.ArrayNumber:
case ChatVarTypeEnum.ArrayObject:
return Array.isArray(value) ? value.filter(Boolean) : []
return Array.isArray(value) ? compactArrayValue(value) : []
case ChatVarTypeEnum.ArrayBoolean:
return value || []
}
@@ -151,6 +153,8 @@ export const parseEditorContent = ({
if (type !== ChatVarTypeEnum.ArrayBoolean)
return parsed
if (!Array.isArray(parsed))
throw new TypeError('ArrayBoolean editor content must be a JSON array')
return parsed
.map((item: string | boolean) => {
if (item === 'True' || item === 'true' || item === true)

View File

@@ -138,7 +138,10 @@ export const ValueSection = ({
<Input
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
value={value as number | undefined}
onChange={e => onArrayChange([Number(e.target.value)])}
onChange={(e) => {
const rawValue = e.target.value
onArrayChange([rawValue === '' ? undefined : Number(rawValue)])
}}
type="number"
/>
)}

View File

@@ -6416,11 +6416,8 @@
}
},
"app/components/workflow-app/hooks/use-workflow-run.ts": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 13
"count": 5
}
},
"app/components/workflow-app/hooks/use-workflow-template.ts": {

View File

@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.13.2",
"version": "1.13.3",
"private": true,
"packageManager": "pnpm@10.32.1",
"imports": {