mirror of
https://github.com/langgenius/dify.git
synced 2026-04-09 15:17:30 +08:00
Compare commits
1 Commits
pyrefly
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
| ca60bb5812 |
@ -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",
|
||||
]
|
||||
|
||||
############################################################
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
24
api/uv.lock
generated
@ -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]]
|
||||
|
||||
46
web/app/components/workflow/__tests__/block-icon.spec.tsx
Normal file
46
web/app/components/workflow/__tests__/block-icon.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
39
web/app/components/workflow/__tests__/context.spec.tsx
Normal file
39
web/app/components/workflow/__tests__/context.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
67
web/app/components/workflow/__tests__/index.spec.tsx
Normal file
67
web/app/components/workflow/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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: '' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
11
web/app/components/workflow/utils/__tests__/plugin.spec.ts
Normal file
11
web/app/components/workflow/utils/__tests__/plugin.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user