Compare commits

..

1 Commits

Author SHA1 Message Date
ca60bb5812 test: add unit tests for workflow components and stores 2026-04-09 14:55:42 +08:00
53 changed files with 2460 additions and 35 deletions

View File

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

View File

@ -5,7 +5,7 @@ from typing import Any
from graphon.model_runtime.entities.provider_entities import FormType
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from configs import dify_config
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
@ -53,12 +53,13 @@ class DatasourceProviderService:
"""
remove oauth custom client params
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
session.query(DatasourceOauthTenantParamConfig).filter_by(
tenant_id=tenant_id,
provider=datasource_provider_id.provider_name,
plugin_id=datasource_provider_id.plugin_id,
).delete()
session.commit()
def decrypt_datasource_provider_credentials(
self,
@ -108,7 +109,7 @@ class DatasourceProviderService:
"""
get credential by id
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
if credential_id:
datasource_provider = (
session.query(DatasourceProvider).filter_by(tenant_id=tenant_id, id=credential_id).first()
@ -155,6 +156,7 @@ class DatasourceProviderService:
datasource_provider=datasource_provider,
)
datasource_provider.expires_at = refreshed_credentials.expires_at
session.commit()
return self.decrypt_datasource_provider_credentials(
tenant_id=tenant_id,
@ -172,7 +174,7 @@ class DatasourceProviderService:
"""
get all datasource credentials by provider
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
datasource_providers = (
session.query(DatasourceProvider)
.filter_by(tenant_id=tenant_id, provider=provider, plugin_id=plugin_id)
@ -222,6 +224,7 @@ class DatasourceProviderService:
provider=provider,
)
real_credentials_list.append(real_credentials)
session.commit()
return real_credentials_list
@ -231,7 +234,7 @@ class DatasourceProviderService:
"""
update datasource provider name
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
target_provider = (
session.query(DatasourceProvider)
.filter_by(
@ -263,6 +266,7 @@ class DatasourceProviderService:
raise ValueError("Authorization name is already exists")
target_provider.name = name
session.commit()
return
def set_default_datasource_provider(
@ -271,7 +275,7 @@ class DatasourceProviderService:
"""
set default datasource provider
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
# get provider
target_provider = (
session.query(DatasourceProvider)
@ -296,6 +300,7 @@ class DatasourceProviderService:
# set new default provider
target_provider.is_default = True
session.commit()
return {"result": "success"}
def setup_oauth_custom_client_params(
@ -310,7 +315,7 @@ class DatasourceProviderService:
"""
if client_params is None and enabled is None:
return
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
tenant_oauth_client_params = (
session.query(DatasourceOauthTenantParamConfig)
.filter_by(
@ -344,6 +349,7 @@ class DatasourceProviderService:
if enabled is not None:
tenant_oauth_client_params.enabled = enabled
session.commit()
def is_system_oauth_params_exist(self, datasource_provider_id: DatasourceProviderID) -> bool:
"""
@ -482,7 +488,7 @@ class DatasourceProviderService:
"""
update datasource oauth provider
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.OAUTH2.value}"
with redis_client.lock(lock, timeout=20):
target_provider = (
@ -529,6 +535,7 @@ class DatasourceProviderService:
target_provider.expires_at = expire_at
target_provider.encrypted_credentials = credentials
target_provider.avatar_url = avatar_url or target_provider.avatar_url
session.commit()
def add_datasource_oauth_provider(
self,
@ -543,7 +550,7 @@ class DatasourceProviderService:
add datasource oauth provider
"""
credential_type = CredentialType.OAUTH2
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{credential_type.value}"
with redis_client.lock(lock, timeout=60):
db_provider_name = name
@ -597,6 +604,7 @@ class DatasourceProviderService:
expires_at=expire_at,
)
session.add(datasource_provider)
session.commit()
def add_datasource_api_key_provider(
self,
@ -615,7 +623,7 @@ class DatasourceProviderService:
provider_name = provider_id.provider_name
plugin_id = provider_id.plugin_id
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
lock = f"datasource_provider_create_lock:{tenant_id}_{provider_id}_{CredentialType.API_KEY}"
with redis_client.lock(lock, timeout=20):
db_provider_name = name or self.generate_next_datasource_provider_name(
@ -662,6 +670,7 @@ class DatasourceProviderService:
encrypted_credentials=credentials,
)
session.add(datasource_provider)
session.commit()
def extract_secret_variables(self, tenant_id: str, provider_id: str, credential_type: CredentialType) -> list[str]:
"""
@ -917,7 +926,7 @@ class DatasourceProviderService:
update datasource credentials.
"""
with sessionmaker(bind=db.engine).begin() as session:
with Session(db.engine) as session:
datasource_provider = (
session.query(DatasourceProvider)
.filter_by(tenant_id=tenant_id, id=auth_id, provider=provider, plugin_id=plugin_id)
@ -971,6 +980,7 @@ class DatasourceProviderService:
encrypted_credentials[key] = value
datasource_provider.encrypted_credentials = encrypted_credentials
session.commit()
def remove_datasource_credentials(self, tenant_id: str, auth_id: str, provider: str, plugin_id: str) -> None:
"""

View File

@ -555,7 +555,7 @@ class RagPipelineService:
workflow_node_execution.id
)
with sessionmaker(bind=db.engine).begin() as session:
with Session(bind=db.engine) as session, session.begin():
draft_var_saver = DraftVariableSaver(
session=session,
app_id=pipeline.id,
@ -569,6 +569,7 @@ class RagPipelineService:
process_data=workflow_node_execution.process_data,
outputs=workflow_node_execution.outputs,
)
session.commit()
if isinstance(workflow_node_execution_db_model, WorkflowNodeExecutionModel):
enqueue_draft_node_execution_trace(
execution=workflow_node_execution_db_model,
@ -1324,7 +1325,7 @@ class RagPipelineService:
# Convert node_execution to WorkflowNodeExecution after save
workflow_node_execution_db_model = repository._to_db_model(workflow_node_execution) # type: ignore
with sessionmaker(bind=db.engine).begin() as session:
with Session(bind=db.engine) as session, session.begin():
draft_var_saver = DraftVariableSaver(
session=session,
app_id=pipeline.id,
@ -1338,6 +1339,7 @@ class RagPipelineService:
process_data=workflow_node_execution.process_data,
outputs=workflow_node_execution.outputs,
)
session.commit()
enqueue_draft_node_execution_trace(
execution=workflow_node_execution_db_model,
outputs=workflow_node_execution.outputs,

View File

@ -1075,8 +1075,9 @@ class DraftVariableSaver:
)
engine = bind = self._session.get_bind()
assert isinstance(engine, Engine)
with sessionmaker(bind=engine, expire_on_commit=False).begin() as session:
with Session(bind=engine, expire_on_commit=False) as session:
session.add(variable_file)
session.commit()
return truncation_result.result, variable_file

View File

@ -837,7 +837,7 @@ class WorkflowService:
with sessionmaker(db.engine).begin() as session:
outputs = workflow_node_execution.load_full_outputs(session, storage)
with sessionmaker(bind=db.engine).begin() as session:
with Session(bind=db.engine) as session, session.begin():
draft_var_saver = DraftVariableSaver(
session=session,
app_id=app_model.id,
@ -848,6 +848,7 @@ class WorkflowService:
user=account,
)
draft_var_saver.save(process_data=node_execution.process_data, outputs=outputs)
session.commit()
enqueue_draft_node_execution_trace(
execution=workflow_node_execution,
@ -976,7 +977,7 @@ class WorkflowService:
enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config)
enclosing_node_id = enclosing_node_type_and_id[1] if enclosing_node_type_and_id else None
with sessionmaker(bind=db.engine).begin() as session:
with Session(bind=db.engine) as session, session.begin():
draft_var_saver = DraftVariableSaver(
session=session,
app_id=app_model.id,
@ -987,6 +988,7 @@ class WorkflowService:
enclosing_node_id=enclosing_node_id,
)
draft_var_saver.save(outputs=outputs, process_data={})
session.commit()
return outputs

View File

@ -40,10 +40,7 @@ class TestDatasourceProviderService:
q returns itself for .filter_by(), .order_by(), .where() so any
SQLAlchemy chaining pattern works without multiple brittle sub-mocks.
"""
with (
patch("services.datasource_provider_service.Session") as mock_cls,
patch("services.datasource_provider_service.sessionmaker") as mock_sm,
):
with patch("services.datasource_provider_service.Session") as mock_cls:
sess = MagicMock(spec=Session)
q = MagicMock()
@ -66,8 +63,6 @@ class TestDatasourceProviderService:
mock_cls.return_value.__enter__.return_value = sess
mock_cls.return_value.no_autoflush.__enter__.return_value = sess
mock_sm.return_value.begin.return_value.__enter__.return_value = sess
mock_sm.return_value.begin.return_value.__exit__ = MagicMock(return_value=False)
yield sess
@ -271,6 +266,7 @@ class TestDatasourceProviderService:
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}),
):
service.get_datasource_credentials("t1", "prov", "org/plug")
mock_db_session.commit.assert_called_once()
def test_should_return_decrypted_credentials_when_api_key_not_expired(self, service, mock_db_session, mock_user):
"""API key credentials with expires_at=-1 skip refresh and return directly."""
@ -337,6 +333,7 @@ class TestDatasourceProviderService:
p.name = "same"
mock_db_session.query().first.return_value = p
service.update_datasource_provider_name("t1", make_id(), "same", "cred-id")
mock_db_session.commit.assert_not_called()
def test_should_raise_value_error_when_name_already_exists(self, service, mock_db_session):
p = MagicMock(spec=DatasourceProvider)
@ -355,6 +352,7 @@ class TestDatasourceProviderService:
mock_db_session.query().count.return_value = 0
service.update_datasource_provider_name("t1", make_id(), "new_name", "some-id")
assert p.name == "new_name"
mock_db_session.commit.assert_called_once()
# -----------------------------------------------------------------------
# set_default_datasource_provider (lines 277-303)
@ -372,6 +370,7 @@ class TestDatasourceProviderService:
mock_db_session.query().first.return_value = target
service.set_default_datasource_provider("t1", make_id(), "new-id")
assert target.is_default is True
mock_db_session.commit.assert_called_once()
# -----------------------------------------------------------------------
# get_oauth_encrypter (lines 404-420)
@ -461,6 +460,7 @@ class TestDatasourceProviderService:
with patch.object(service, "extract_secret_variables", return_value=[]):
service.add_datasource_oauth_provider("new", "t1", make_id(), "http://cb", 9999, {})
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
def test_should_auto_rename_when_oauth_provider_name_conflicts(self, service, mock_db_session):
"""Conflict on name results in auto-incremented name, not an error."""
@ -512,6 +512,7 @@ class TestDatasourceProviderService:
mock_db_session.query().count.return_value = 0
with patch.object(service, "extract_secret_variables", return_value=[]):
service.reauthorize_datasource_oauth_provider("n", "t1", make_id(), "u", 1, {}, "oid")
mock_db_session.commit.assert_called_once()
def test_should_auto_rename_when_reauth_name_conflicts(self, service, mock_db_session):
p = MagicMock(spec=DatasourceProvider)
@ -522,6 +523,7 @@ class TestDatasourceProviderService:
service.reauthorize_datasource_oauth_provider(
"conflict_name", "t1", make_id(), "u", 9999, {"tok": "v"}, "cred-id"
)
mock_db_session.commit.assert_called_once()
def test_should_encrypt_secret_fields_when_reauthorizing(self, service, mock_db_session):
p = MagicMock(spec=DatasourceProvider)
@ -569,6 +571,7 @@ class TestDatasourceProviderService:
):
service.add_datasource_api_key_provider(None, "t1", make_id(), {"sk": "v"})
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
def test_should_acquire_redis_lock_when_adding_api_key_provider(self, service, mock_db_session, mock_user):
mock_db_session.query().count.return_value = 0
@ -744,6 +747,7 @@ class TestDatasourceProviderService:
# encrypter must have been called with the new secret value
self._enc.encrypt_token.assert_called()
# commit must be called exactly once
mock_db_session.commit.assert_called_once()
# -----------------------------------------------------------------------
# remove_datasource_credentials (lines 980-997)
@ -754,6 +758,7 @@ class TestDatasourceProviderService:
mock_db_session.scalar.return_value = p
service.remove_datasource_credentials("t1", "id", "prov", "org/plug")
mock_db_session.delete.assert_called_once_with(p)
mock_db_session.commit.assert_called_once()
def test_should_do_nothing_when_credential_not_found_on_remove(self, service, mock_db_session):
"""No error raised; no delete called when record doesn't exist (lines 994 branch)."""

24
api/uv.lock generated
View File

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

View File

@ -0,0 +1,46 @@
import { render } from '@testing-library/react'
import { API_PREFIX } from '@/config'
import BlockIcon, { VarBlockIcon } from '../block-icon'
import { BlockEnum } from '../types'
describe('BlockIcon', () => {
it('renders the default workflow icon container for regular nodes', () => {
const { container } = render(<BlockIcon type={BlockEnum.Start} size="xs" className="extra-class" />)
const iconContainer = container.firstElementChild
expect(iconContainer).toHaveClass('w-4', 'h-4', 'bg-util-colors-blue-brand-blue-brand-500', 'extra-class')
expect(iconContainer?.querySelector('svg')).toBeInTheDocument()
})
it('normalizes protected plugin icon urls for tool-like nodes', () => {
const { container } = render(
<BlockIcon
type={BlockEnum.Tool}
toolIcon="/foo/workspaces/current/plugin/icon/plugin-tool.png"
/>,
)
const iconContainer = container.firstElementChild as HTMLElement
const backgroundIcon = iconContainer.querySelector('div') as HTMLElement
expect(iconContainer).not.toHaveClass('bg-util-colors-blue-blue-500')
expect(backgroundIcon.style.backgroundImage).toContain(
`${API_PREFIX}/workspaces/current/plugin/icon/plugin-tool.png`,
)
})
})
describe('VarBlockIcon', () => {
it('renders the compact icon variant without the default container wrapper', () => {
const { container } = render(
<VarBlockIcon
type={BlockEnum.Answer}
className="custom-var-icon"
/>,
)
expect(container.querySelector('.custom-var-icon')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.querySelector('.bg-util-colors-warning-warning-500')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowContextProvider } from '../context'
import { useStore, useWorkflowStore } from '../store'
const StoreConsumer = () => {
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
const store = useWorkflowStore()
return (
<button onClick={() => store.getState().setShowSingleRunPanel(!showSingleRunPanel)}>
{showSingleRunPanel ? 'open' : 'closed'}
</button>
)
}
describe('WorkflowContextProvider', () => {
it('provides the workflow store to descendants and keeps the same store across rerenders', async () => {
const user = userEvent.setup()
const { rerender } = render(
<WorkflowContextProvider>
<StoreConsumer />
</WorkflowContextProvider>,
)
expect(screen.getByRole('button', { name: 'closed' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'closed' }))
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
rerender(
<WorkflowContextProvider>
<StoreConsumer />
</WorkflowContextProvider>,
)
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,67 @@
import type { Edge, Node } from '../types'
import { render, screen } from '@testing-library/react'
import { useStoreApi } from 'reactflow'
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
import WorkflowWithDefaultContext from '../index'
import { BlockEnum } from '../types'
import { useWorkflowHistoryStore } from '../workflow-history-store'
const nodes: Node[] = [
{
id: 'node-start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
},
]
const edges: Edge[] = [
{
id: 'edge-1',
source: 'node-start',
target: 'node-end',
sourceHandle: null,
targetHandle: null,
type: 'custom',
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.End,
},
},
]
const ContextConsumer = () => {
const { store, shortcutsEnabled } = useWorkflowHistoryStore()
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
const reactFlowStore = useStoreApi()
return (
<div>
{`history:${store.getState().nodes.length}`}
{` shortcuts:${String(shortcutsEnabled)}`}
{` datasets:${datasetCount}`}
{` reactflow:${String(!!reactFlowStore)}`}
</div>
)
}
describe('WorkflowWithDefaultContext', () => {
it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
render(
<WorkflowWithDefaultContext
nodes={nodes}
edges={edges}
>
<ContextConsumer />
</WorkflowWithDefaultContext>,
)
expect(
screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
).toBeInTheDocument()
})
})

View File

@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react'
import ShortcutsName from '../shortcuts-name'
describe('ShortcutsName', () => {
const originalNavigator = globalThis.navigator
afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
writable: true,
configurable: true,
})
})
it('renders mac-friendly key labels and style variants', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Macintosh' },
writable: true,
configurable: true,
})
const { container } = render(
<ShortcutsName
keys={['ctrl', 'shift', 's']}
bgColor="white"
textColor="secondary"
/>,
)
expect(screen.getByText('⌘')).toBeInTheDocument()
expect(screen.getByText('⇧')).toBeInTheDocument()
expect(screen.getByText('s')).toBeInTheDocument()
expect(container.querySelector('.system-kbd')).toHaveClass(
'bg-components-kbd-bg-white',
'text-text-tertiary',
)
})
it('keeps raw key names on non-mac systems', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Windows NT' },
writable: true,
configurable: true,
})
render(<ShortcutsName keys={['ctrl', 'alt']} />)
expect(screen.getByText('ctrl')).toBeInTheDocument()
expect(screen.getByText('alt')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,97 @@
import type { Edge, Node } from '../types'
import type { WorkflowHistoryState } from '../workflow-history-store'
import { render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../types'
import { useWorkflowHistoryStore, WorkflowHistoryProvider } from '../workflow-history-store'
const nodes: Node[] = [
{
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
selected: true,
},
selected: true,
},
]
const edges: Edge[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: null,
targetHandle: null,
type: 'custom',
selected: true,
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.End,
},
},
]
const HistoryConsumer = () => {
const { store, shortcutsEnabled, setShortcutsEnabled } = useWorkflowHistoryStore()
return (
<button onClick={() => setShortcutsEnabled(!shortcutsEnabled)}>
{`nodes:${store.getState().nodes.length} shortcuts:${String(shortcutsEnabled)}`}
</button>
)
}
describe('WorkflowHistoryProvider', () => {
it('provides workflow history state and shortcut toggles', async () => {
const user = userEvent.setup()
render(
<WorkflowHistoryProvider
nodes={nodes}
edges={edges}
>
<HistoryConsumer />
</WorkflowHistoryProvider>,
)
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:false' })).toBeInTheDocument()
})
it('sanitizes selected flags when history state is replaced through the exposed store api', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<WorkflowHistoryProvider
nodes={nodes}
edges={edges}
>
{children}
</WorkflowHistoryProvider>
)
const { result } = renderHook(() => useWorkflowHistoryStore(), { wrapper })
const nextState: WorkflowHistoryState = {
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
nodes,
edges,
}
result.current.store.setState(nextState)
expect(result.current.store.getState().nodes[0].data.selected).toBe(false)
expect(result.current.store.getState().edges[0].selected).toBe(false)
})
it('throws when consumed outside the provider', () => {
expect(() => renderHook(() => useWorkflowHistoryStore())).toThrow(
'useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider',
)
})
})

View File

@ -0,0 +1,91 @@
import type { DataSet } from '@/models/datasets'
import { renderHook } from '@testing-library/react'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { DatasetsDetailContext } from '../provider'
import { createDatasetsDetailStore, useDatasetsDetailStore } from '../store'
const createDataset = (id: string, name = `dataset-${id}`): DataSet => ({
id,
name,
indexing_status: 'completed',
icon_info: {
icon: 'book',
icon_type: 'emoji' as DataSet['icon_info']['icon_type'],
},
description: `${name} description`,
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
word_count: 0,
provider: 'provider',
embedding_model: 'model',
embedding_model_provider: 'provider',
embedding_available: true,
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
retrieval_model: {} as DataSet['retrieval_model'],
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 1,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'general',
enable_api: false,
is_multimodal: false,
})
describe('datasets-detail-store store', () => {
it('merges dataset details by id', () => {
const store = createDatasetsDetailStore()
store.getState().updateDatasetsDetail([
createDataset('dataset-1', 'Dataset One'),
createDataset('dataset-2', 'Dataset Two'),
])
store.getState().updateDatasetsDetail([
createDataset('dataset-2', 'Dataset Two Updated'),
])
expect(store.getState().datasetsDetail).toMatchObject({
'dataset-1': { name: 'Dataset One' },
'dataset-2': { name: 'Dataset Two Updated' },
})
})
it('reads state from the datasets detail context', () => {
const store = createDatasetsDetailStore()
store.getState().updateDatasetsDetail([createDataset('dataset-3')])
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DatasetsDetailContext.Provider value={store}>
{children}
</DatasetsDetailContext.Provider>
)
const { result } = renderHook(
() => useDatasetsDetailStore(state => state.datasetsDetail['dataset-3']?.name),
{ wrapper },
)
expect(result.current).toBe('dataset-dataset-3')
})
it('throws when the datasets detail provider is missing', () => {
expect(() => renderHook(() => useDatasetsDetailStore(state => state.datasetsDetail))).toThrow(
'Missing DatasetsDetailContext.Provider in the tree',
)
})
})

View File

@ -0,0 +1,41 @@
import { renderHook } from '@testing-library/react'
import { HooksStoreContext } from '../provider'
import { createHooksStore, useHooksStore } from '../store'
describe('hooks-store store', () => {
it('creates default callbacks and refreshes selected handlers', () => {
const store = createHooksStore({})
const handleBackupDraft = vi.fn()
expect(store.getState().availableNodesMetaData).toEqual({ nodes: [] })
expect(store.getState().hasNodeInspectVars('node-1')).toBe(false)
expect(store.getState().getWorkflowRunAndTraceUrl('run-1')).toEqual({
runUrl: '',
traceUrl: '',
})
store.getState().refreshAll({ handleBackupDraft })
expect(store.getState().handleBackupDraft).toBe(handleBackupDraft)
})
it('reads state from the hooks store context', () => {
const handleRun = vi.fn()
const store = createHooksStore({ handleRun })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<HooksStoreContext.Provider value={store}>
{children}
</HooksStoreContext.Provider>
)
const { result } = renderHook(() => useHooksStore(state => state.handleRun), { wrapper })
expect(result.current).toBe(handleRun)
})
it('throws when the hooks store provider is missing', () => {
expect(() => renderHook(() => useHooksStore(state => state.handleRun))).toThrow(
'Missing HooksStoreContext.Provider in the tree',
)
})
})

View File

@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react'
import { useNote } from '../hooks'
const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn())
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
vi.mock('../../hooks', () => ({
useNodeDataUpdate: () => ({
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
}),
useWorkflowHistory: () => ({
saveStateToHistory: mockSaveStateToHistory,
}),
WorkflowHistoryEvent: {
NoteChange: 'note-change',
},
}))
describe('useNote', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updates theme and author visibility while saving note history entries', () => {
const { result } = renderHook(() => useNote('note-1'))
act(() => {
result.current.handleThemeChange('blue' as never)
result.current.handleShowAuthorChange(true)
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(1, {
id: 'note-1',
data: { theme: 'blue' },
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
id: 'note-1',
data: { showAuthor: true },
})
expect(mockSaveStateToHistory).toHaveBeenNthCalledWith(1, 'note-change', { nodeId: 'note-1' })
expect(mockSaveStateToHistory).toHaveBeenNthCalledWith(2, 'note-change', { nodeId: 'note-1' })
})
it('serializes non-empty editor state and clears empty editor state', () => {
const { result } = renderHook(() => useNote('note-2'))
const nonEmptyEditorState = {
isEmpty: () => false,
} as never
const emptyEditorState = {
isEmpty: () => true,
} as never
act(() => {
result.current.handleEditorChange(nonEmptyEditorState)
result.current.handleEditorChange(emptyEditorState)
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(1, {
id: 'note-2',
data: { text: '{}' },
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
id: 'note-2',
data: { text: '' },
})
})
})

View File

@ -0,0 +1,77 @@
import { act, renderHook } from '@testing-library/react'
import NoteEditorContext from '../context'
import { createNoteEditorStore, useNoteEditorStore, useStore } from '../store'
describe('note editor store', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('stores selection state and link metadata', () => {
const store = createNoteEditorStore()
store.getState().setLinkOperatorShow(true)
store.getState().setSelectedIsBold(true)
store.getState().setSelectedIsItalic(true)
store.getState().setSelectedIsStrikeThrough(true)
store.getState().setSelectedLinkUrl('https://dify.ai')
store.getState().setSelectedIsLink(true)
store.getState().setSelectedIsBullet(true)
expect(store.getState()).toMatchObject({
linkOperatorShow: true,
selectedIsBold: true,
selectedIsItalic: true,
selectedIsStrikeThrough: true,
selectedLinkUrl: 'https://dify.ai',
selectedIsLink: true,
selectedIsBullet: true,
})
})
it('resolves the current selection parent as the link anchor element', () => {
vi.useFakeTimers()
const store = createNoteEditorStore()
const parent = document.createElement('span')
const textNode = document.createTextNode('selected-text')
parent.appendChild(textNode)
vi.spyOn(window, 'getSelection').mockReturnValue({
focusNode: textNode,
} as unknown as Selection)
store.getState().setLinkAnchorElement(true)
vi.runAllTimers()
expect(store.getState().linkAnchorElement).toBe(parent)
store.getState().setLinkAnchorElement(false)
expect(store.getState().linkAnchorElement).toBeNull()
vi.useRealTimers()
})
it('reads note editor state from context hooks', () => {
const store = createNoteEditorStore()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<NoteEditorContext.Provider value={store}>
{children}
</NoteEditorContext.Provider>
)
const { result: selectedResult } = renderHook(() => useStore(state => state.selectedIsBold), { wrapper })
const { result: storeResult } = renderHook(() => useNoteEditorStore(), { wrapper })
act(() => {
storeResult.current.getState().setSelectedIsBold(true)
})
expect(selectedResult.current).toBe(true)
expect(storeResult.current).toBe(store)
})
it('throws when the note editor store provider is missing', () => {
expect(() => renderHook(() => useStore(state => state.selectedIsBold))).toThrow(
'Missing NoteEditorContext.Provider in the tree',
)
})
})

View File

@ -0,0 +1,39 @@
import type { TextNode } from 'lexical'
import { getSelectedNode, urlRegExp } from '../utils'
const mockIsAtNodeEnd = vi.hoisted(() => vi.fn())
vi.mock('@lexical/selection', () => ({
$isAtNodeEnd: (...args: unknown[]) => mockIsAtNodeEnd(...args),
}))
describe('note editor utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the shared node directly when anchor and focus point to the same node', () => {
const node = { key: 'same-node' } as unknown as TextNode
const selection = {
anchor: { getNode: () => node },
focus: { getNode: () => node },
isBackward: () => false,
} as never
expect(getSelectedNode(selection)).toBe(node)
})
it('chooses the focus node when selecting backward from the middle of the node', () => {
const anchorNode = { key: 'anchor' } as unknown as TextNode
const focusNode = { key: 'focus' } as unknown as TextNode
mockIsAtNodeEnd.mockReturnValue(false)
const selection = {
anchor: { getNode: () => anchorNode },
focus: { getNode: () => focusNode },
isBackward: () => true,
} as never
expect(getSelectedNode(selection)).toBe(focusNode)
expect(urlRegExp.test('https://dify.ai/docs')).toBe(true)
})
})

View File

@ -0,0 +1,172 @@
import { act, renderHook } from '@testing-library/react'
import { useFormatDetector } from '../hooks'
type MockParent = {
isLink?: boolean
getURL?: () => string
isListItem?: boolean
} | null
const {
mockRegisterUpdateListener,
mockSelection,
mockIsRangeSelection,
mockSelectedNode,
mockSetSelectedIsBold,
mockSetSelectedIsItalic,
mockSetSelectedIsStrikeThrough,
mockSetSelectedLinkUrl,
mockSetSelectedIsLink,
mockSetSelectedIsBullet,
mockEditorIsComposing,
mockUpdateListener,
} = vi.hoisted(() => ({
mockRegisterUpdateListener: vi.fn(),
mockSelection: {
hasFormat: vi.fn((format: string) => format === 'bold'),
},
mockIsRangeSelection: vi.fn(() => true),
mockSelectedNode: {
isLink: false,
isListItem: false,
getURL: vi.fn(() => ''),
getParent: vi.fn<() => MockParent>(() => null),
},
mockSetSelectedIsBold: vi.fn(),
mockSetSelectedIsItalic: vi.fn(),
mockSetSelectedIsStrikeThrough: vi.fn(),
mockSetSelectedLinkUrl: vi.fn(),
mockSetSelectedIsLink: vi.fn(),
mockSetSelectedIsBullet: vi.fn(),
mockEditorIsComposing: vi.fn(() => false),
mockUpdateListener: { current: undefined as undefined | (() => void) },
}))
vi.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: () => ([{
isComposing: mockEditorIsComposing,
getEditorState: () => ({
read: (callback: () => void) => callback(),
}),
registerUpdateListener: mockRegisterUpdateListener,
}]),
}))
vi.mock('@lexical/utils', () => ({
mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()),
}))
vi.mock('@lexical/link', () => ({
$isLinkNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isLink' in (node as object) && (node as { isLink?: boolean }).isLink),
}))
vi.mock('@lexical/list', () => ({
$isListItemNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isListItem' in (node as object) && (node as { isListItem?: boolean }).isListItem),
}))
vi.mock('lexical', () => ({
$getSelection: () => mockSelection,
$isRangeSelection: () => mockIsRangeSelection(),
}))
vi.mock('../../../store', () => ({
useNoteEditorStore: () => ({
getState: () => ({
setSelectedIsBold: mockSetSelectedIsBold,
setSelectedIsItalic: mockSetSelectedIsItalic,
setSelectedIsStrikeThrough: mockSetSelectedIsStrikeThrough,
setSelectedLinkUrl: mockSetSelectedLinkUrl,
setSelectedIsLink: mockSetSelectedIsLink,
setSelectedIsBullet: mockSetSelectedIsBullet,
}),
}),
}))
vi.mock('../../../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../utils')>()
return {
...actual,
getSelectedNode: () => mockSelectedNode,
}
})
describe('format detector hook', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsRangeSelection.mockReturnValue(true)
mockEditorIsComposing.mockReturnValue(false)
mockSelection.hasFormat.mockImplementation((format: string) => format === 'bold' || format === 'strikethrough')
mockSelectedNode.isLink = false
mockSelectedNode.isListItem = false
mockSelectedNode.getURL.mockReturnValue('')
mockSelectedNode.getParent.mockReturnValue(null)
mockRegisterUpdateListener.mockImplementation((listener) => {
mockUpdateListener.current = () => listener({})
return vi.fn()
})
})
// Selection updates should mirror formatting, link, and list state into the note editor store.
describe('Formatting Detection', () => {
it('should sync bold, italic, strikethrough, link, and bullet state from the current selection', () => {
mockSelectedNode.isLink = true
mockSelectedNode.getURL.mockReturnValue('https://dify.ai')
mockSelectedNode.getParent.mockReturnValue({ isListItem: true })
const { result } = renderHook(() => useFormatDetector())
act(() => {
result.current.handleFormat()
})
expect(mockSetSelectedIsBold).toHaveBeenCalledWith(true)
expect(mockSetSelectedIsItalic).toHaveBeenCalledWith(false)
expect(mockSetSelectedIsStrikeThrough).toHaveBeenCalledWith(true)
expect(mockSetSelectedLinkUrl).toHaveBeenCalledWith('https://dify.ai')
expect(mockSetSelectedIsLink).toHaveBeenCalledWith(true)
expect(mockSetSelectedIsBullet).toHaveBeenCalledWith(true)
})
it('should clear link and bullet state for a plain text selection triggered by selectionchange', () => {
const addListenerSpy = vi.spyOn(document, 'addEventListener')
const removeListenerSpy = vi.spyOn(document, 'removeEventListener')
const { unmount } = renderHook(() => useFormatDetector())
act(() => {
document.dispatchEvent(new Event('selectionchange'))
})
expect(addListenerSpy).toHaveBeenCalledWith('selectionchange', expect.any(Function))
expect(mockSetSelectedLinkUrl).toHaveBeenCalledWith('')
expect(mockSetSelectedIsLink).toHaveBeenCalledWith(false)
expect(mockSetSelectedIsBullet).toHaveBeenCalledWith(false)
unmount()
expect(removeListenerSpy).toHaveBeenCalledWith('selectionchange', expect.any(Function))
})
it('should ignore format detection while the editor is composing', () => {
mockEditorIsComposing.mockReturnValue(true)
const { result } = renderHook(() => useFormatDetector())
act(() => {
result.current.handleFormat()
})
expect(mockSetSelectedIsBold).not.toHaveBeenCalled()
expect(mockSetSelectedLinkUrl).not.toHaveBeenCalled()
})
it('should react to lexical update events', () => {
renderHook(() => useFormatDetector())
act(() => {
mockUpdateListener.current?.()
})
expect(mockSetSelectedIsBold).toHaveBeenCalledWith(true)
expect(mockSetSelectedIsItalic).toHaveBeenCalledWith(false)
})
})
})

View File

@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react'
import NoteEditorContext from '../../../context'
import { createNoteEditorStore } from '../../../store'
import LinkEditorComponent from '../component'
const mockHandleSaveLink = vi.hoisted(() => vi.fn())
const mockHandleUnlink = vi.hoisted(() => vi.fn())
vi.mock('../hooks', () => ({
useLink: () => ({
handleSaveLink: mockHandleSaveLink,
handleUnlink: mockHandleUnlink,
}),
}))
describe('link editor component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the inline link editor and saves the edited url', () => {
const store = createNoteEditorStore()
const anchor = document.createElement('button')
const portalRoot = document.createElement('div')
document.body.appendChild(anchor)
document.body.appendChild(portalRoot)
store.setState({
linkAnchorElement: anchor,
linkOperatorShow: false,
selectedLinkUrl: 'https://example.com',
})
render(
<NoteEditorContext.Provider value={store}>
<LinkEditorComponent containerElement={portalRoot} />
</NoteEditorContext.Provider>,
)
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.ok' }))
expect(mockHandleSaveLink).toHaveBeenCalledWith('https://example.com')
})
})

View File

@ -0,0 +1,180 @@
import { act, renderHook } from '@testing-library/react'
import { useLink, useOpenLink } from '../hooks'
const {
mockDispatchCommand,
mockRegisterUpdateListener,
mockRegisterCommand,
mockSetLinkAnchorElement,
mockSetLinkOperatorShow,
mockToastError,
mockEditor,
mockStoreState,
mockListeners,
} = vi.hoisted(() => {
const listeners: {
update?: () => void
click?: (payload: { metaKey?: boolean, ctrlKey?: boolean }) => boolean
} = {}
const editor = {
dispatchCommand: vi.fn(),
registerUpdateListener: vi.fn(),
registerCommand: vi.fn(),
}
return {
mockDispatchCommand: editor.dispatchCommand,
mockRegisterUpdateListener: editor.registerUpdateListener,
mockRegisterCommand: editor.registerCommand,
mockSetLinkAnchorElement: vi.fn(),
mockSetLinkOperatorShow: vi.fn(),
mockToastError: vi.fn(),
mockEditor: editor,
mockStoreState: {
selectedIsLink: false,
selectedLinkUrl: '',
setLinkAnchorElement: vi.fn(),
setLinkOperatorShow: vi.fn(),
},
mockListeners: listeners,
}
})
vi.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: () => ([mockEditor]),
}))
vi.mock('@lexical/utils', () => ({
mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()),
}))
vi.mock('@lexical/link', () => ({
TOGGLE_LINK_COMMAND: 'toggle-link-command',
}))
vi.mock('lexical', () => ({
CLICK_COMMAND: 'click-command',
COMMAND_PRIORITY_LOW: 1,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('../../../store', () => ({
useNoteEditorStore: () => ({
getState: () => mockStoreState,
}),
}))
describe('link editor hooks', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockStoreState.selectedIsLink = false
mockStoreState.selectedLinkUrl = ''
mockStoreState.setLinkAnchorElement = mockSetLinkAnchorElement
mockStoreState.setLinkOperatorShow = mockSetLinkOperatorShow
mockListeners.update = undefined
mockListeners.click = undefined
mockRegisterUpdateListener.mockImplementation((listener) => {
mockListeners.update = listener
return vi.fn()
})
mockRegisterCommand.mockImplementation((_command, listener) => {
mockListeners.click = listener
return vi.fn()
})
})
afterEach(() => {
vi.useRealTimers()
})
// Link hover and click state should follow the selected link metadata in the editor store.
describe('useOpenLink', () => {
it('should show the link operator when the current selection is a link with a URL', () => {
mockStoreState.selectedIsLink = true
mockStoreState.selectedLinkUrl = 'https://dify.ai'
renderHook(() => useOpenLink())
act(() => {
mockListeners.update?.()
vi.runAllTimers()
})
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith(true)
expect(mockSetLinkOperatorShow).toHaveBeenCalledWith(true)
})
it('should clear the link operator when the current selection is not a link', () => {
renderHook(() => useOpenLink())
act(() => {
mockListeners.update?.()
vi.runAllTimers()
})
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith()
expect(mockSetLinkOperatorShow).toHaveBeenCalledWith(false)
})
it('should open the selected link in a new tab on meta or ctrl click', () => {
mockStoreState.selectedIsLink = true
mockStoreState.selectedLinkUrl = 'https://dify.ai'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
renderHook(() => useOpenLink())
let handled = false
act(() => {
handled = mockListeners.click?.({ metaKey: true }) ?? false
vi.runAllTimers()
})
expect(handled).toBe(false)
expect(openSpy).toHaveBeenCalledWith('https://dify.ai', '_blank')
})
})
// Saving and removing links should dispatch the lexical link command and close the operator.
describe('useLink', () => {
it('should reject invalid URLs and show an error toast', () => {
const { result } = renderHook(() => useLink())
act(() => {
result.current.handleSaveLink('not-a-valid-url')
})
expect(mockToastError).toHaveBeenCalledWith('workflow.nodes.note.editor.invalidUrl')
expect(mockDispatchCommand).not.toHaveBeenCalled()
})
it('should save a valid link and clear the anchor element', () => {
const { result } = renderHook(() => useLink())
act(() => {
result.current.handleSaveLink('https://dify.ai/docs')
})
expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', 'https://dify.ai/docs')
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith()
})
it('should remove the current link and clear the anchor element', () => {
const { result } = renderHook(() => useLink())
act(() => {
result.current.handleUnlink()
})
expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', null)
expect(mockSetLinkAnchorElement).toHaveBeenCalledWith()
})
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import Divider from '../divider'
describe('note editor toolbar divider', () => {
it('renders the visual separator used inside the toolbar', () => {
const { container } = render(<Divider />)
expect(container.firstChild).toHaveClass('mx-1', 'h-3.5', 'w-px', 'bg-divider-regular')
})
})

View File

@ -0,0 +1,54 @@
import type { ChatVariableSliceShape } from '../chat-variable-slice'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { createStore } from 'zustand/vanilla'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import { createChatVariableSlice } from '../chat-variable-slice'
type ChatPanelState = ChatVariableSliceShape & {
showDebugAndPreviewPanel?: boolean
showEnvPanel?: boolean
}
describe('createChatVariableSlice', () => {
it('toggles chat and global variable panels while clearing the other overlay states', () => {
const store = createStore(createChatVariableSlice)
store.getState().setShowChatVariablePanel(true)
const chatState = store.getState() as ChatPanelState
expect(chatState).toMatchObject({
showChatVariablePanel: true,
showGlobalVariablePanel: false,
showEnvPanel: false,
showDebugAndPreviewPanel: false,
})
store.getState().setShowGlobalVariablePanel(true)
const globalState = store.getState() as ChatPanelState
expect(globalState).toMatchObject({
showChatVariablePanel: false,
showGlobalVariablePanel: true,
showEnvPanel: false,
showDebugAndPreviewPanel: false,
})
store.getState().setShowGlobalVariablePanel(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
})
it('stores conversation variables', () => {
const store = createStore(createChatVariableSlice)
const conversationVariables: ConversationVariable[] = [
{
id: 'conversation-id',
name: 'Conversation ID',
value_type: ChatVarType.String,
value: 'abc',
description: 'Current conversation id',
},
]
store.getState().setConversationVariables(conversationVariables)
expect(store.getState().conversationVariables).toEqual(conversationVariables)
})
})

View File

@ -0,0 +1,49 @@
import type { EnvVariableSliceShape } from '../env-variable-slice'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { createStore } from 'zustand/vanilla'
import { createEnvVariableSlice } from '../env-variable-slice'
type EnvPanelState = EnvVariableSliceShape & {
showDebugAndPreviewPanel?: boolean
showChatVariablePanel?: boolean
showGlobalVariablePanel?: boolean
}
describe('createEnvVariableSlice', () => {
it('opens the env panel while clearing other overlay states', () => {
const store = createStore(createEnvVariableSlice)
store.getState().setShowEnvPanel(true)
const state = store.getState() as EnvPanelState
expect(state).toMatchObject({
showEnvPanel: true,
showDebugAndPreviewPanel: false,
showChatVariablePanel: false,
showGlobalVariablePanel: false,
})
store.getState().setShowEnvPanel(false)
expect(store.getState().showEnvPanel).toBe(false)
})
it('stores environment variables and secrets', () => {
const store = createStore(createEnvVariableSlice)
const environmentVariables: EnvironmentVariable[] = [
{
id: 'env-1',
name: 'API_KEY',
value: 'test',
value_type: 'secret',
description: 'secret env',
},
]
store.getState().setEnvironmentVariables(environmentVariables)
store.getState().setEnvSecrets({ API_KEY: 'masked' })
expect(store.getState().environmentVariables).toEqual(environmentVariables)
expect(store.getState().envSecrets).toEqual({ API_KEY: 'masked' })
})
})

View File

@ -0,0 +1,31 @@
import type { RunFile } from '@/app/components/workflow/types'
import { createStore } from 'zustand/vanilla'
import { TransferMethod } from '@/types/app'
import { createFormSlice } from '../form-slice'
describe('createFormSlice', () => {
it('stores runtime inputs and uploaded files', () => {
const store = createStore(createFormSlice)
const files: RunFile[] = [
{
type: 'image',
transfer_method: [TransferMethod.local_file],
upload_file_id: 'file-1',
},
]
store.getState().setInputs({
prompt: 'hello',
retries: 2,
enabled: true,
})
store.getState().setFiles(files)
expect(store.getState().inputs).toEqual({
prompt: 'hello',
retries: 2,
enabled: true,
})
expect(store.getState().files).toEqual(files)
})
})

View File

@ -0,0 +1,17 @@
import { createStore } from 'zustand/vanilla'
import { createHelpLineSlice } from '../help-line-slice'
describe('createHelpLineSlice', () => {
it('starts empty and updates both guideline positions', () => {
const store = createStore(createHelpLineSlice)
expect(store.getState().helpLineHorizontal).toBeUndefined()
expect(store.getState().helpLineVertical).toBeUndefined()
store.getState().setHelpLineHorizontal({ top: 12, left: 0, width: 420 })
store.getState().setHelpLineVertical({ top: 0, left: 24, height: 320 })
expect(store.getState().helpLineHorizontal).toEqual({ top: 12, left: 0, width: 420 })
expect(store.getState().helpLineVertical).toEqual({ top: 0, left: 24, height: 320 })
})
})

View File

@ -0,0 +1,38 @@
import type { HistoryWorkflowData } from '@/app/components/workflow/types'
import type { VersionHistory } from '@/types/workflow'
import { createStore } from 'zustand/vanilla'
import { createHistorySlice } from '../history-slice'
describe('createHistorySlice', () => {
it('stores history workflow data and version history state', () => {
const store = createStore(createHistorySlice)
const historyWorkflowData = {
nodes: [],
edges: [],
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: {
enabled: false,
},
},
environment_variables: [],
conversation_variables: [],
} as unknown as HistoryWorkflowData
const versionHistory = [
{
id: 'version-1',
created_at: 1,
created_by: 'user-1',
},
] as unknown as VersionHistory[]
store.getState().setHistoryWorkflowData(historyWorkflowData)
store.getState().setShowRunHistory(true)
store.getState().setVersionHistory(versionHistory)
expect(store.getState().historyWorkflowData).toBe(historyWorkflowData)
expect(store.getState().showRunHistory).toBe(true)
expect(store.getState().versionHistory).toBe(versionHistory)
})
})

View File

@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react'
import { WorkflowContext } from '../../../context'
import { createWorkflowStore, useStore, useWorkflowStore } from '../index'
describe('workflow store index', () => {
it('creates a merged workflow store and exposes it through both hooks', () => {
const store = createWorkflowStore({})
const wrapper = ({ children }: { children: React.ReactNode }) => (
<WorkflowContext.Provider value={store}>
{children}
</WorkflowContext.Provider>
)
const { result } = renderHook(() => ({
nodes: useStore(state => state.nodes),
sameStore: useWorkflowStore() === store,
}), { wrapper })
expect(result.current.nodes).toEqual([])
expect(result.current.sameStore).toBe(true)
})
})

View File

@ -0,0 +1,36 @@
import { createStore } from 'zustand/vanilla'
import { createLayoutSlice } from '../layout-slice'
describe('createLayoutSlice', () => {
beforeEach(() => {
localStorage.clear()
})
it('reads persisted panel sizes and maximize state from localStorage', () => {
localStorage.setItem('workflow-node-panel-width', '460')
localStorage.setItem('debug-and-preview-panel-width', '520')
localStorage.setItem('workflow-variable-inpsect-panel-height', '240')
localStorage.setItem('workflow-canvas-maximize', 'true')
const store = createStore(createLayoutSlice)
expect(store.getState().nodePanelWidth).toBe(460)
expect(store.getState().previewPanelWidth).toBe(520)
expect(store.getState().variableInspectPanelHeight).toBe(240)
expect(store.getState().maximizeCanvas).toBe(true)
})
it('updates canvas and panel dimensions through the slice setters', () => {
const store = createStore(createLayoutSlice)
store.getState().setWorkflowCanvasWidth(1280)
store.getState().setWorkflowCanvasHeight(720)
store.getState().setBottomPanelWidth(640)
store.getState().setBottomPanelHeight(360)
expect(store.getState().workflowCanvasWidth).toBe(1280)
expect(store.getState().workflowCanvasHeight).toBe(720)
expect(store.getState().bottomPanelWidth).toBe(640)
expect(store.getState().bottomPanelHeight).toBe(360)
})
})

View File

@ -0,0 +1,35 @@
import { createStore } from 'zustand/vanilla'
import { createNodeSlice } from '../node-slice'
describe('createNodeSlice', () => {
it('keeps node-level ui defaults and updates transient payloads', () => {
const store = createStore(createNodeSlice)
expect(store.getState().showSingleRunPanel).toBe(false)
expect(store.getState().iterTimes).toBe(1)
expect(store.getState().loopTimes).toBe(1)
expect(store.getState().iterParallelLogMap.size).toBe(0)
store.getState().setConnectingNodePayload({
nodeId: 'node-1',
nodeType: 'llm',
handleType: 'source',
handleId: 'output',
})
store.getState().setPendingSingleRun({
nodeId: 'node-1',
action: 'run',
})
expect(store.getState().connectingNodePayload).toEqual({
nodeId: 'node-1',
nodeType: 'llm',
handleType: 'source',
handleId: 'output',
})
expect(store.getState().pendingSingleRun).toEqual({
nodeId: 'node-1',
action: 'run',
})
})
})

View File

@ -0,0 +1,30 @@
import { createStore } from 'zustand/vanilla'
import { createPanelSlice } from '../panel-slice'
describe('createPanelSlice', () => {
beforeEach(() => {
localStorage.clear()
})
it('uses the persisted panel width when present', () => {
localStorage.setItem('workflow-node-panel-width', '480')
const store = createStore(createPanelSlice)
expect(store.getState().panelWidth).toBe(480)
})
it('updates panel visibility and context menus through the slice setters', () => {
const store = createStore(createPanelSlice)
store.getState().setShowFeaturesPanel(true)
store.getState().setShowDebugAndPreviewPanel(true)
store.getState().setPanelMenu({ top: 24, left: 48 })
store.getState().setEdgeMenu({ clientX: 80, clientY: 120, edgeId: 'edge-1' })
expect(store.getState().showFeaturesPanel).toBe(true)
expect(store.getState().showDebugAndPreviewPanel).toBe(true)
expect(store.getState().panelMenu).toEqual({ top: 24, left: 48 })
expect(store.getState().edgeMenu).toEqual({ clientX: 80, clientY: 120, edgeId: 'edge-1' })
})
})

View File

@ -0,0 +1,17 @@
import { createStore } from 'zustand/vanilla'
import { createToolSlice } from '../tool-slice'
describe('createToolSlice', () => {
it('tracks tool publish state flags', () => {
const store = createStore(createToolSlice)
expect(store.getState().toolPublished).toBe(false)
expect(store.getState().lastPublishedHasUserInput).toBe(false)
store.getState().setToolPublished(true)
store.getState().setLastPublishedHasUserInput(true)
expect(store.getState().toolPublished).toBe(true)
expect(store.getState().lastPublishedHasUserInput).toBe(true)
})
})

View File

@ -0,0 +1,29 @@
import type { Node } from '../../../types'
import { renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { BlockEnum } from '../../../types'
import useWorkflowNodes from '../use-nodes'
describe('useWorkflowNodes', () => {
it('reads nodes from the real workflow store context', () => {
const nodes: Node[] = [
{
id: 'node-start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
},
]
const { result } = renderWorkflowHook(() => useWorkflowNodes(), {
initialStoreState: {
nodes,
},
})
expect(result.current).toEqual(nodes)
})
})

View File

@ -0,0 +1,43 @@
import type { VersionHistory } from '@/types/workflow'
import { createStore } from 'zustand/vanilla'
import { createVersionSlice } from '../version-slice'
describe('createVersionSlice', () => {
it('stores timestamps in milliseconds and tracks restore state', () => {
const store = createStore(createVersionSlice)
const currentVersion: VersionHistory = {
id: 'version-2',
graph: {
nodes: [],
edges: [],
},
created_at: 2,
created_by: {
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
},
hash: 'hash-version-2',
updated_at: 2,
updated_by: {
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
},
tool_published: false,
version: '2',
marked_name: '',
marked_comment: '',
}
store.getState().setDraftUpdatedAt(10)
store.getState().setPublishedAt(15)
store.getState().setCurrentVersion(currentVersion)
store.getState().setIsRestoring(true)
expect(store.getState().draftUpdatedAt).toBe(10_000)
expect(store.getState().publishedAt).toBe(15_000)
expect(store.getState().currentVersion).toBe(currentVersion)
expect(store.getState().isRestoring).toBe(true)
})
})

View File

@ -0,0 +1,22 @@
import { createStore } from 'zustand/vanilla'
import { createWorkflowDraftSlice } from '../workflow-draft-slice'
describe('createWorkflowDraftSlice', () => {
it('stores draft metadata and flushes pending sync work', () => {
const store = createStore(createWorkflowDraftSlice)
const syncWorkflowDraft = vi.fn()
store.getState().setSyncWorkflowDraftHash('draft-hash')
store.getState().setIsSyncingWorkflowDraft(true)
store.getState().setIsWorkflowDataLoaded(true)
store.getState().setNodes([{ id: 'node-1' }] as never)
store.getState().debouncedSyncWorkflowDraft(syncWorkflowDraft)
store.getState().flushPendingSync()
expect(store.getState().syncWorkflowDraftHash).toBe('draft-hash')
expect(store.getState().isSyncingWorkflowDraft).toBe(true)
expect(store.getState().isWorkflowDataLoaded).toBe(true)
expect(store.getState().nodes).toEqual([{ id: 'node-1' }])
expect(syncWorkflowDraft).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,41 @@
import type { WorkflowRunningData } from '../../../types'
import { createStore } from 'zustand/vanilla'
import { WorkflowRunningStatus } from '../../../types'
import { createWorkflowSlice } from '../workflow-slice'
describe('createWorkflowSlice', () => {
beforeEach(() => {
localStorage.clear()
})
it('defaults to hand mode until a persisted pointer mode is present', () => {
const defaultStore = createStore(createWorkflowSlice)
expect(defaultStore.getState().controlMode).toBe('hand')
localStorage.setItem('workflow-operation-mode', 'pointer')
const persistedStore = createStore(createWorkflowSlice)
expect(persistedStore.getState().controlMode).toBe('pointer')
})
it('persists control mode updates and stores run state payloads', () => {
const store = createStore(createWorkflowSlice)
const workflowRunningData: WorkflowRunningData & { resultText: string } = {
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
resultText: 'streaming',
}
store.getState().setControlMode('pointer')
store.getState().setWorkflowRunningData(workflowRunningData)
expect(store.getState().controlMode).toBe('pointer')
expect(localStorage.getItem('workflow-operation-mode')).toBe('pointer')
expect(store.getState().workflowRunningData?.resultText).toBe('streaming')
})
})

View File

@ -0,0 +1,100 @@
import type { VarInInspect } from '@/types/workflow'
import { createStore } from 'zustand/vanilla'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import { createInspectVarsSlice } from '../inspect-vars-slice'
const createInspectVar = (overrides?: Partial<VarInInspect>): VarInInspect => ({
id: 'var-1',
type: VarInInspectType.node,
name: 'answer',
description: 'Inspect variable',
selector: ['node-1', 'answer'],
value_type: VarType.string,
value: 'initial',
edited: false,
visible: true,
is_truncated: false,
full_content: {
size_bytes: 7,
download_url: 'https://example.com/inspect-var.txt',
},
...overrides,
})
describe('createInspectVarsSlice', () => {
it('updates focus state and replaces node inspect variables', () => {
const store = createStore(createInspectVarsSlice)
const vars = [createInspectVar()]
store.getState().setCurrentFocusNodeId('node-1')
store.getState().setNodesWithInspectVars([
{
nodeId: 'node-1',
nodePayload: { type: BlockEnum.Start, title: 'Start', desc: '' } as never,
nodeType: BlockEnum.Start,
title: 'Start',
vars: [],
},
])
store.getState().setNodeInspectVars('node-1', vars)
expect(store.getState().currentFocusNodeId).toBe('node-1')
expect(store.getState().nodesWithInspectVars[0]).toMatchObject({
nodeId: 'node-1',
isValueFetched: true,
vars,
})
})
it('edits, renames, resets, and deletes inspect vars', () => {
const store = createStore(createInspectVarsSlice)
store.getState().setNodesWithInspectVars([
{
nodeId: 'node-1',
nodePayload: { type: BlockEnum.Start, title: 'Start', desc: '' } as never,
nodeType: BlockEnum.Start,
title: 'Start',
vars: [createInspectVar()],
},
])
store.getState().setInspectVarValue('node-1', 'var-1', 'edited')
expect(store.getState().nodesWithInspectVars[0].vars[0]).toMatchObject({
value: 'edited',
edited: true,
})
store.getState().renameInspectVarName('node-1', 'var-1', ['node-1', 'renamed'])
expect(store.getState().nodesWithInspectVars[0].vars[0]).toMatchObject({
name: 'renamed',
selector: ['node-1', 'renamed'],
})
store.getState().resetToLastRunVar('node-1', 'var-1', 'restored')
expect(store.getState().nodesWithInspectVars[0].vars[0]).toMatchObject({
value: 'restored',
edited: false,
})
store.getState().deleteInspectVar('node-1', 'var-1')
expect(store.getState().nodesWithInspectVars[0].vars).toEqual([])
store.getState().deleteNodeInspectVars('node-1')
expect(store.getState().nodesWithInspectVars).toEqual([])
store.getState().setNodesWithInspectVars([
{
nodeId: 'node-2',
nodePayload: { type: BlockEnum.Start, title: 'Start', desc: '' } as never,
nodeType: BlockEnum.Start,
title: 'Start',
vars: [createInspectVar({ id: 'var-2' })],
},
])
store.getState().deleteAllInspectVars()
expect(store.getState().nodesWithInspectVars).toEqual([])
})
})

View File

@ -0,0 +1,11 @@
import { extractPluginId } from '../plugin'
describe('extractPluginId', () => {
it('returns the provider prefix for nested plugin paths', () => {
expect(extractPluginId('langgenius/openai/tools/chat')).toBe('langgenius/openai')
})
it('returns the original provider when it has no nested path', () => {
expect(extractPluginId('langgenius')).toBe('langgenius')
})
})

View File

@ -0,0 +1,27 @@
import { fireEvent, render, screen } from '@testing-library/react'
import DisplayContent from '../display-content'
import { PreviewType } from '../types'
describe('variable inspect display content', () => {
it('renders markdown code view and forwards text edits', () => {
const handleTextChange = vi.fn()
render(
<DisplayContent
previewType={PreviewType.Markdown}
varType={'string' as never}
mdString="hello markdown"
readonly={false}
handleTextChange={handleTextChange}
/>,
)
expect(screen.getByText('MARKDOWN')).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'updated markdown' },
})
expect(handleTextChange).toHaveBeenCalledWith('updated markdown')
})
})

View File

@ -0,0 +1,14 @@
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import VariableInspectPanel from '../index'
describe('variable inspect index', () => {
it('renders nothing when the inspect panel is hidden', () => {
const { container } = renderWorkflowComponent(<VariableInspectPanel />, {
initialStoreState: {
showVariableInspectPanel: false,
},
})
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -0,0 +1,50 @@
import { fireEvent, screen } from '@testing-library/react'
import { createTriggerNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import Listening from '../listening'
const mockCopy = vi.hoisted(() => vi.fn())
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({
useGetToolIcon: () => () => 'tool-icon',
}))
describe('variable inspect listening', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the listening message and forwards the stop action', () => {
const onStop = vi.fn()
renderWorkflowFlowComponent(<Listening onStop={onStop} message="Waiting for webhook payload" />, {
nodes: [
createTriggerNode(BlockEnum.TriggerWebhook, {
id: 'trigger-1',
data: {
title: 'Webhook Trigger',
webhook_debug_url: 'https://example.com/debug',
},
}),
],
edges: [],
initialStoreState: {
listeningTriggerType: BlockEnum.TriggerWebhook,
listeningTriggerNodeId: 'trigger-1',
listeningTriggerNodeIds: [],
listeningTriggerIsAll: false,
},
})
expect(screen.getByText('Waiting for webhook payload')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.listening.stopButton' }))
expect(onStop).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,16 @@
import { validateJSONSchema } from '../utils'
describe('validateJSONSchema', () => {
it('accepts supported schema payloads for each structured type', () => {
expect(validateJSONSchema(['a', 'b'], 'array[string]').success).toBe(true)
expect(validateJSONSchema([1, 2], 'array[number]').success).toBe(true)
expect(validateJSONSchema({ answer: ['a', 1, false] }, 'object').success).toBe(true)
expect(validateJSONSchema([{ answer: 'ok' }], 'array[object]').success).toBe(true)
})
it('rejects invalid structured payloads and skips unknown types', () => {
expect(validateJSONSchema(['a', 1], 'array[string]').success).toBe(false)
expect(validateJSONSchema([{ answer: 'ok' }], 'array[number]').success).toBe(false)
expect(validateJSONSchema('plain-text', 'custom').success).toBe(true)
})
})

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import { Position } from 'reactflow'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import CustomEdge from '../custom-edge'
vi.mock('reactflow', () => ({
BaseEdge: (props: {
id: string
path: string
style: {
stroke: string
strokeWidth: number
opacity: number
}
}) => (
<div
data-testid="base-edge"
data-id={props.id}
data-path={props.path}
data-stroke={props.style.stroke}
data-stroke-width={props.style.strokeWidth}
data-opacity={props.style.opacity}
/>
),
getBezierPath: () => ['M 0 0'],
Position: {
Right: 'right',
Left: 'left',
},
}))
describe('workflow preview custom edge', () => {
it('renders a gradient edge when both ends have finished statuses', () => {
const edgeProps: React.ComponentProps<typeof CustomEdge> = {
id: 'edge-1',
source: 'source-node',
target: 'target-node',
sourceX: 100,
sourceY: 120,
targetX: 260,
targetY: 120,
sourcePosition: Position.Right,
targetPosition: Position.Left,
selected: false,
data: {
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: true,
} as never,
}
const { container } = render(
<svg>
<CustomEdge {...edgeProps} />
</svg>,
)
expect(container.querySelector('linearGradient#edge-1')).toBeInTheDocument()
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.7')
})
it('prefers the running stroke color when the edge is selected', () => {
const edgeProps: React.ComponentProps<typeof CustomEdge> = {
id: 'edge-selected',
source: 'source-node',
target: 'target-node',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 100,
sourcePosition: Position.Right,
targetPosition: Position.Left,
selected: true,
data: {
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: NodeRunningStatus.Succeeded,
} as never,
}
render(
<svg>
<CustomEdge {...edgeProps} />
</svg>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)')
})
})

View File

@ -0,0 +1,123 @@
import { fireEvent, render, within } from '@testing-library/react'
import ZoomInOut from '../zoom-in-out'
const {
mockZoomIn,
mockZoomOut,
mockZoomTo,
mockFitView,
mockViewport,
} = vi.hoisted(() => ({
mockZoomIn: vi.fn(),
mockZoomOut: vi.fn(),
mockZoomTo: vi.fn(),
mockFitView: vi.fn(),
mockViewport: { zoom: 1 },
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
zoomIn: mockZoomIn,
zoomOut: mockZoomOut,
zoomTo: mockZoomTo,
fitView: mockFitView,
}),
useViewport: () => mockViewport,
}))
const getZoomControls = () => {
const label = Array.from(document.querySelectorAll('div')).find((element) => {
return /^\d+%$/.test(element.textContent ?? '') && element.className.includes('w-[34px]')
})
const icons = Array.from(document.querySelectorAll('svg'))
if (!label || icons.length < 2)
throw new Error('Missing zoom controls')
return {
zoomOutTrigger: icons[0].parentElement as HTMLElement,
label,
zoomInTrigger: icons[1].parentElement as HTMLElement,
}
}
const openZoomMenu = () => {
fireEvent.click(getZoomControls().label)
const portal = document.querySelector('[data-floating-ui-portal]')
if (!portal)
throw new Error('Missing zoom menu portal')
return within(portal as HTMLElement)
}
describe('workflow preview zoom controls', () => {
beforeEach(() => {
vi.clearAllMocks()
mockViewport.zoom = 1
})
// The inline controls should call the reactflow zoom handlers when the viewport is within range.
describe('Inline Controls', () => {
it('should zoom out and zoom in when the viewport is within the supported range', () => {
render(<ZoomInOut />)
const { zoomOutTrigger, zoomInTrigger } = getZoomControls()
fireEvent.click(zoomOutTrigger)
fireEvent.click(zoomInTrigger)
expect(mockZoomOut).toHaveBeenCalledTimes(1)
expect(mockZoomIn).toHaveBeenCalledTimes(1)
})
it('should block zooming out when the viewport is already at the minimum scale', () => {
mockViewport.zoom = 0.25
render(<ZoomInOut />)
fireEvent.click(getZoomControls().zoomOutTrigger)
expect(mockZoomOut).not.toHaveBeenCalled()
})
it('should block zooming in when the viewport is already at the maximum scale', () => {
mockViewport.zoom = 2
render(<ZoomInOut />)
fireEvent.click(getZoomControls().zoomInTrigger)
expect(mockZoomIn).not.toHaveBeenCalled()
})
})
// Preset menu actions should route to the correct reactflow commands.
describe('Preset Menu', () => {
it.each([
['25%', 0.25],
['50%', 0.5],
['75%', 0.75],
['100%', 1],
['200%', 2],
])('should zoom to %s when selecting that preset', (label, zoom) => {
render(<ZoomInOut />)
const portal = openZoomMenu()
fireEvent.click(portal.getByText(label))
expect(mockZoomTo).toHaveBeenCalledWith(zoom)
expect(mockFitView).not.toHaveBeenCalled()
})
it('should fit the viewport when selecting the fit option', () => {
render(<ZoomInOut />)
const portal = openZoomMenu()
fireEvent.click(portal.getByText('workflow.operator.zoomToFit'))
expect(mockFitView).toHaveBeenCalledTimes(1)
expect(mockZoomTo).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,63 @@
import type { NodeProps } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import BaseCard from '../base'
vi.mock('reactflow', () => ({
Handle: (props: { id: string, type: string, className?: string }) => (
<div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} />
),
Position: {
Left: 'left',
Right: 'right',
},
}))
const ChildNode = ({ id, data }: Partial<NodeProps>) => (
<div data-testid="base-node-child">{`${id}:${data?.title}`}</div>
)
describe('workflow preview base node card', () => {
it('renders the title, description, child content, and connection handles', () => {
render(
<BaseCard
id="node-1"
data={{
type: BlockEnum.Answer,
title: 'Answer node',
desc: 'This is a preview node',
} as never}
>
<ChildNode />
</BaseCard>,
)
expect(screen.getByText('Answer node')).toBeInTheDocument()
expect(screen.getByText('This is a preview node')).toBeInTheDocument()
expect(screen.getByTestId('base-node-child')).toHaveTextContent('node-1:Answer node')
expect(document.querySelector('[data-handleid="target"]')).toBeInTheDocument()
expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument()
})
it('uses the iteration layout and parallel badge for iteration nodes', () => {
render(
<BaseCard
id="iteration-1"
data={{
type: BlockEnum.Iteration,
title: 'Iteration node',
desc: 'Ignored description',
width: 360,
height: 220,
is_parallel: true,
} as never}
>
<ChildNode />
</BaseCard>,
)
expect(screen.getByTestId('base-node-child')).toHaveTextContent('iteration-1:Iteration node')
expect(screen.getByText('workflow.nodes.iteration.parallelModeUpper')).toBeInTheDocument()
expect(screen.queryByText('Ignored description')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,44 @@
import { render } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import CustomNode from '../index'
vi.mock('reactflow', () => ({
Handle: (props: { id: string, type: string, className?: string }) => (
<div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} />
),
Position: {
Left: 'left',
Right: 'right',
},
}))
describe('workflow preview custom node', () => {
it('renders the mapped node component inside the shared base card', () => {
const props: React.ComponentProps<typeof CustomNode> = {
id: 'classifier-1',
type: 'custom-node',
selected: false,
zIndex: 1,
isConnectable: true,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data: {
type: BlockEnum.QuestionClassifier,
title: 'Classifier node',
desc: '',
classes: [
{ id: 'class-a', name: 'Billing' },
],
} as never,
}
const { container, getByText } = render(
<CustomNode {...props} />,
)
expect(getByText('Classifier node')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="class-a"]')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,57 @@
import { render } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
vi.mock('reactflow', () => ({
Handle: (props: { id: string, type: string, className?: string }) => (
<div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} />
),
Position: {
Right: 'right',
},
}))
describe('workflow preview if-else node', () => {
it('shows the condition-not-setup state for incomplete cases', () => {
const props: React.ComponentProps<typeof Node> = {
id: 'if-else-1',
type: 'if-else-node',
selected: false,
zIndex: 1,
isConnectable: true,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data: {
type: BlockEnum.IfElse,
title: 'If else',
desc: '',
isInIteration: false,
isInLoop: false,
cases: [
{
case_id: 'case-1',
logical_operator: 'and',
conditions: [
{
id: 'condition-1',
variable_selector: [],
comparison_operator: 'contains',
value: 'hello',
},
],
},
],
} as never,
}
const { container, getByText } = render(
<Node {...props} />,
)
expect(getByText('workflow.nodes.ifElse.conditionNotSetup')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="case-1"]')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="false"]')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,35 @@
import { render } from '@testing-library/react'
import IterationStartNode from '..'
vi.mock('reactflow', () => ({
Handle: (props: { id: string, type: string, className?: string }) => (
<div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} />
),
Position: {
Right: 'right',
},
}))
describe('workflow preview iteration-start node', () => {
it('renders the start marker and source handle', () => {
const props: React.ComponentProps<typeof IterationStartNode> = {
id: 'iteration-start-1',
type: 'iteration-start-node',
selected: false,
zIndex: 1,
isConnectable: true,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data: {},
}
const { container } = render(
<IterationStartNode {...props} />,
)
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('rounded-2xl')
})
})

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import IterationNode from '../node'
vi.mock('reactflow', () => ({
Background: (props: {
id: string
gap: [number, number]
size: number
color: string
}) => (
<div
data-testid="background"
data-id={props.id}
data-gap={JSON.stringify(props.gap)}
data-size={props.size}
data-color={props.color}
/>
),
useViewport: () => ({
zoom: 2,
}),
}))
describe('workflow preview iteration node', () => {
it('scales the dotted background with the current viewport zoom', () => {
render(
<IterationNode
id="iteration-1"
data={{} as never}
/>,
)
expect(screen.getByTestId('background')).toHaveAttribute('data-id', 'iteration-background-iteration-1')
expect(screen.getByTestId('background')).toHaveAttribute('data-gap', '[7,7]')
expect(screen.getByTestId('background')).toHaveAttribute('data-size', '1')
})
})

View File

@ -0,0 +1,35 @@
import { render } from '@testing-library/react'
import LoopStartNode from '..'
vi.mock('reactflow', () => ({
Handle: (props: { id: string, type: string, className?: string }) => (
<div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} />
),
Position: {
Right: 'right',
},
}))
describe('workflow preview loop-start node', () => {
it('renders the start marker and source handle', () => {
const props: React.ComponentProps<typeof LoopStartNode> = {
id: 'loop-start-1',
type: 'loop-start-node',
selected: false,
zIndex: 1,
isConnectable: true,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data: {},
}
const { container } = render(
<LoopStartNode {...props} />,
)
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('rounded-2xl')
})
})

View File

@ -0,0 +1,53 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { createLoopNode, createNode } from '@/app/components/workflow/__tests__/fixtures'
import { LOOP_PADDING } from '@/app/components/workflow/constants'
import { useNodeLoopInteractions } from '../hooks'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
Position: {
Left: 'left',
Right: 'right',
},
}))
describe('workflow preview loop interactions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('expands the loop node when children overflow the current bounds', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopRerender('loop-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedLoopNode = mockSetNodes.mock.calls[0][0].find((node: Node) => node.id === 'loop-node')
expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right)
expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom)
})
})

View File

@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react'
import LoopNode from '../node'
const mockHandleNodeLoopRerender = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
Background: (props: {
id: string
}) => <div data-testid="background" data-id={props.id} />,
useViewport: () => ({
zoom: 1,
}),
useNodesInitialized: () => true,
}))
vi.mock('../hooks', () => ({
useNodeLoopInteractions: () => ({
handleNodeLoopRerender: mockHandleNodeLoopRerender,
}),
}))
describe('workflow preview loop node', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rerenders the loop bounds once child nodes are initialized', () => {
render(
<LoopNode
id="loop-1"
data={{} as never}
/>,
)
expect(screen.getByTestId('background')).toHaveAttribute('data-id', 'loop-background-loop-1')
expect(mockHandleNodeLoopRerender).toHaveBeenCalledWith('loop-1')
})
})

View File

@ -0,0 +1,45 @@
import { render } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
vi.mock('reactflow', () => ({
Handle: (props: { id: string, type: string, className?: string }) => (
<div data-testid="handle" data-handleid={props.id} data-type={props.type} className={props.className} />
),
Position: {
Right: 'right',
},
}))
describe('workflow preview question classifier node', () => {
it('renders one output handle per configured class', () => {
const props: React.ComponentProps<typeof Node> = {
id: 'classifier-1',
type: 'question-classifier-node',
selected: false,
zIndex: 1,
isConnectable: true,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data: {
type: BlockEnum.QuestionClassifier,
title: 'Classifier',
desc: '',
classes: [
{ id: 'class-1', name: 'Billing' },
{ id: 'class-2', name: 'Support' },
],
} as never,
}
const { container, getByText } = render(
<Node {...props} />,
)
expect(getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="class-1"]')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="class-2"]')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,82 @@
import type { NoteNodeType } from '@/app/components/workflow/note-node/types'
import { render, screen } from '@testing-library/react'
import { NoteTheme } from '@/app/components/workflow/note-node/types'
import NoteNode from '../index'
const emptyValue = JSON.stringify({ root: { children: [] } })
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
title: 'Note',
desc: '',
type: 'note' as NoteNodeType['type'],
text: emptyValue,
theme: NoteTheme.blue,
author: 'Alice',
showAuthor: true,
width: 320,
height: 180,
selected: false,
...overrides,
})
const createNoteProps = (overrides: Partial<React.ComponentProps<typeof NoteNode>> = {}): React.ComponentProps<typeof NoteNode> => ({
id: 'note-node-1',
type: 'note-node',
selected: false,
zIndex: 1,
isConnectable: true,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data: createNoteData(),
...overrides,
})
describe('workflow preview note node', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The preview node should expose the same readonly editor surface and metadata as the live note node.
describe('Rendering', () => {
it('should render the readonly note editor, author, and themed frame', () => {
const { container } = render(
<NoteNode {...createNoteProps()} />,
)
const noteRoot = container.firstElementChild as HTMLElement
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.note.editor.placeholder')).toBeInTheDocument()
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(noteRoot).toHaveClass('bg-util-colors-blue-blue-50', 'border-black/5')
expect(noteRoot).toHaveStyle({
width: '320px',
height: '180px',
})
})
it('should apply selected styles and hide the author block when showAuthor is disabled', () => {
const { container } = render(
<NoteNode
{...createNoteProps({
selected: true,
data: createNoteData({
theme: NoteTheme.pink,
selected: true,
showAuthor: false,
}),
})}
/>,
)
const noteRoot = container.firstElementChild as HTMLElement
expect(noteRoot).toHaveClass('bg-util-colors-pink-pink-50', 'border-util-colors-pink-pink-300')
expect(container.querySelector('.cursor-text')).toBeInTheDocument()
expect(container.querySelector('.nodrag.nopan.nowheel')).toBeInTheDocument()
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
})
})
})