Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
1add10aa92 chore(deps): bump pypdf from 6.7.1 to 6.7.2 in /api
Bumps [pypdf](https://github.com/py-pdf/pypdf) from 6.7.1 to 6.7.2.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.7.1...6.7.2)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.7.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 16:45:57 +00:00
Ijas
daa923278e fix: type checking error in parser (#32510)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-26 01:24:59 +09:00
Asuka Minato
7b1b5c2445 test: example for [Refactor/Chore] use Testcontainers to do sql test #32454 (#32459) 2026-02-25 23:22:20 +08:00
heyszt
154486bc7b feat(aliyun-trace): add app_id attribute (#32489)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-25 23:20:44 +08:00
Pandaaaa906
fd799fa3f4 fix: spin-animation animation-delay (#32560)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-02-25 23:17:08 +08:00
非法操作
065122a2ae fix: incorrect placeholder color in dark mode (#32568) 2026-02-25 23:15:51 +08:00
Poojan
b5f62b98f9 test: add unit tests for base-components-part-5 (#32457)
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
2026-02-25 22:13:10 +08:00
Poojan
0ac09127c7 test: add unit tests for base components-part-4 (#32452)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
2026-02-25 17:36:58 +08:00
木之本澪
3c69bac2b1 test: migrate dataset service retrieval SQL tests to testcontainers (#32528)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-25 18:13:07 +09:00
76 changed files with 9600 additions and 836 deletions

View File

@@ -14,6 +14,7 @@ from core.ops.aliyun_trace.data_exporter.traceclient import (
)
from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData, TraceMetadata
from core.ops.aliyun_trace.entities.semconv import (
DIFY_APP_ID,
GEN_AI_COMPLETION,
GEN_AI_INPUT_MESSAGE,
GEN_AI_OUTPUT_MESSAGE,
@@ -99,6 +100,16 @@ class AliyunDataTrace(BaseTraceInstance):
logger.info("Aliyun get project url failed: %s", str(e), exc_info=True)
raise ValueError(f"Aliyun get project url failed: {str(e)}")
def _extract_app_id(self, trace_info: BaseTraceInfo) -> str:
"""Extract app_id from trace_info, trying metadata first then message_data."""
app_id = trace_info.metadata.get("app_id")
if app_id:
return str(app_id)
message_data = getattr(trace_info, "message_data", None)
if message_data is not None:
return str(getattr(message_data, "app_id", ""))
return ""
def workflow_trace(self, trace_info: WorkflowTraceInfo):
trace_metadata = TraceMetadata(
trace_id=convert_to_trace_id(trace_info.workflow_run_id),
@@ -143,13 +154,16 @@ class AliyunDataTrace(BaseTraceInstance):
name="message",
start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
attributes=create_common_span_attributes(
session_id=trace_metadata.session_id,
user_id=trace_metadata.user_id,
span_kind=GenAISpanKind.CHAIN,
inputs=inputs_json,
outputs=outputs_str,
),
attributes={
**create_common_span_attributes(
session_id=trace_metadata.session_id,
user_id=trace_metadata.user_id,
span_kind=GenAISpanKind.CHAIN,
inputs=inputs_json,
outputs=outputs_str,
),
DIFY_APP_ID: self._extract_app_id(trace_info),
},
status=status,
links=trace_metadata.links,
span_kind=SpanKind.SERVER,
@@ -441,6 +455,8 @@ class AliyunDataTrace(BaseTraceInstance):
inputs_json = serialize_json_data(trace_info.workflow_run_inputs)
outputs_json = serialize_json_data(trace_info.workflow_run_outputs)
app_id = self._extract_app_id(trace_info)
if message_span_id:
message_span = SpanData(
trace_id=trace_metadata.trace_id,
@@ -449,13 +465,16 @@ class AliyunDataTrace(BaseTraceInstance):
name="message",
start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
attributes=create_common_span_attributes(
session_id=trace_metadata.session_id,
user_id=trace_metadata.user_id,
span_kind=GenAISpanKind.CHAIN,
inputs=trace_info.workflow_run_inputs.get("sys.query") or "",
outputs=outputs_json,
),
attributes={
**create_common_span_attributes(
session_id=trace_metadata.session_id,
user_id=trace_metadata.user_id,
span_kind=GenAISpanKind.CHAIN,
inputs=trace_info.workflow_run_inputs.get("sys.query") or "",
outputs=outputs_json,
),
DIFY_APP_ID: app_id,
},
status=status,
links=trace_metadata.links,
span_kind=SpanKind.SERVER,
@@ -469,13 +488,16 @@ class AliyunDataTrace(BaseTraceInstance):
name="workflow",
start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
attributes=create_common_span_attributes(
session_id=trace_metadata.session_id,
user_id=trace_metadata.user_id,
span_kind=GenAISpanKind.CHAIN,
inputs=inputs_json,
outputs=outputs_json,
),
attributes={
**create_common_span_attributes(
session_id=trace_metadata.session_id,
user_id=trace_metadata.user_id,
span_kind=GenAISpanKind.CHAIN,
inputs=inputs_json,
outputs=outputs_json,
),
**({DIFY_APP_ID: app_id} if message_span_id is None else {}),
},
status=status,
links=trace_metadata.links,
span_kind=SpanKind.SERVER if message_span_id is None else SpanKind.INTERNAL,

View File

@@ -3,6 +3,9 @@ from typing import Final
ACS_ARMS_SERVICE_FEATURE: Final[str] = "acs.arms.service.feature"
# Dify-specific attributes
DIFY_APP_ID: Final[str] = "dify.app_id"
# Public attributes
GEN_AI_SESSION_ID: Final[str] = "gen_ai.session.id"
GEN_AI_USER_ID: Final[str] = "gen_ai.user.id"

View File

@@ -14,7 +14,7 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParamet
from core.tools.errors import ToolApiSchemaError, ToolNotSupportedError, ToolProviderNotFoundError
class _OpenAPIInterface(TypedDict):
class InterfaceDict(TypedDict):
path: str
method: str
operation: dict[str, Any]
@@ -41,17 +41,17 @@ class ApiBasedToolSchemaParser:
server_url = matched_servers[0] if matched_servers else server_url
# list all interfaces
interfaces: list[_OpenAPIInterface] = []
interfaces: list[InterfaceDict] = []
for path, path_item in openapi["paths"].items():
methods = ["get", "post", "put", "delete", "patch", "head", "options", "trace"]
for method in methods:
if method in path_item:
interfaces.append(
_OpenAPIInterface(
path=path,
method=method,
operation=path_item[method],
)
{
"path": path,
"method": method,
"operation": path_item[method],
}
)
# get all parameters

View File

@@ -6,11 +6,15 @@ import pytest
import sqlalchemy as sa
from sqlalchemy import exc as sa_exc
from sqlalchemy import insert
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy.sql.sqltypes import VARCHAR
from models.types import EnumText
_USER_TABLE = "enum_text_users"
_COLUMN_TABLE = "enum_text_column_test"
_user_type_admin = "admin"
_user_type_normal = "normal"
@@ -30,7 +34,7 @@ class _EnumWithLongValue(StrEnum):
class _User(_Base):
__tablename__ = "users"
__tablename__ = _USER_TABLE
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
name: Mapped[str] = mapped_column(sa.String(length=255), nullable=False)
@@ -41,7 +45,7 @@ class _User(_Base):
class _ColumnTest(_Base):
__tablename__ = "column_test"
__tablename__ = _COLUMN_TABLE
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
@@ -64,13 +68,30 @@ def _first(it: Iterable[_T]) -> _T:
return ls[0]
class TestEnumText:
def test_column_impl(self):
engine = sa.create_engine("sqlite://", echo=False)
_Base.metadata.create_all(engine)
def _resolve_engine(bind: Engine | Connection) -> Engine:
if isinstance(bind, Engine):
return bind
return bind.engine
inspector = sa.inspect(engine)
columns = inspector.get_columns(_ColumnTest.__tablename__)
@pytest.fixture
def engine_with_containers(db_session_with_containers: Session) -> Engine:
return _resolve_engine(db_session_with_containers.get_bind())
@pytest.fixture(autouse=True)
def _enum_text_schema(engine_with_containers: Engine) -> Iterable[None]:
_Base.metadata.create_all(engine_with_containers)
try:
yield
finally:
_Base.metadata.drop_all(engine_with_containers)
class TestEnumText:
def test_column_impl(self, engine_with_containers: Engine):
inspector = sa.inspect(engine_with_containers)
columns = inspector.get_columns(_COLUMN_TABLE)
user_type_column = _first(c for c in columns if c["name"] == "user_type")
sql_type = user_type_column["type"]
@@ -89,11 +110,8 @@ class TestEnumText:
assert isinstance(sql_type, VARCHAR)
assert sql_type.length == len(_EnumWithLongValue.a_really_long_enum_values)
def test_insert_and_select(self):
engine = sa.create_engine("sqlite://", echo=False)
_Base.metadata.create_all(engine)
with Session(engine) as session:
def test_insert_and_select(self, engine_with_containers: Engine):
with Session(engine_with_containers) as session:
admin_user = _User(
name="admin",
user_type=_UserType.admin,
@@ -113,17 +131,17 @@ class TestEnumText:
normal_user_id = normal_user.id
session.commit()
with Session(engine) as session:
with Session(engine_with_containers) as session:
user = session.query(_User).where(_User.id == admin_user_id).first()
assert user.user_type == _UserType.admin
assert user.user_type_nullable is None
with Session(engine) as session:
with Session(engine_with_containers) as session:
user = session.query(_User).where(_User.id == normal_user_id).first()
assert user.user_type == _UserType.normal
assert user.user_type_nullable == _UserType.normal
def test_insert_invalid_values(self):
def test_insert_invalid_values(self, engine_with_containers: Engine):
def _session_insert_with_value(sess: Session, user_type: Any):
user = _User(name="test_user", user_type=user_type)
sess.add(user)
@@ -143,8 +161,6 @@ class TestEnumText:
action: Callable[[Session], None]
exc_type: type[Exception]
engine = sa.create_engine("sqlite://", echo=False)
_Base.metadata.create_all(engine)
cases = [
TestCase(
name="session insert with invalid value",
@@ -169,23 +185,22 @@ class TestEnumText:
]
for idx, c in enumerate(cases, 1):
with pytest.raises(sa_exc.StatementError) as exc:
with Session(engine) as session:
with Session(engine_with_containers) as session:
c.action(session)
assert isinstance(exc.value.orig, c.exc_type), f"test case {idx} failed, name={c.name}"
def test_select_invalid_values(self):
engine = sa.create_engine("sqlite://", echo=False)
_Base.metadata.create_all(engine)
insertion_sql = """
INSERT INTO users (id, name, user_type) VALUES
def test_select_invalid_values(self, engine_with_containers: Engine):
insertion_sql = f"""
INSERT INTO {_USER_TABLE} (id, name, user_type) VALUES
(1, 'invalid_value', 'invalid');
"""
with Session(engine) as session:
with Session(engine_with_containers) as session:
session.execute(sa.text(insertion_sql))
session.commit()
with pytest.raises(ValueError) as exc:
with Session(engine) as session:
with Session(engine_with_containers) as session:
_user = session.query(_User).where(_User.id == 1).first()
assert str(exc.value) == "'invalid' is not a valid _UserType"

View File

@@ -0,0 +1,643 @@
"""
Comprehensive integration tests for DatasetService retrieval/list methods.
This test suite covers:
- get_datasets - pagination, search, filtering, permissions
- get_dataset - single dataset retrieval
- get_datasets_by_ids - bulk retrieval
- get_process_rules - dataset processing rules
- get_dataset_queries - dataset query history
- get_related_apps - apps using the dataset
"""
import json
from uuid import uuid4
from extensions.ext_database import db
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.dataset import (
AppDatasetJoin,
Dataset,
DatasetPermission,
DatasetPermissionEnum,
DatasetProcessRule,
DatasetQuery,
)
from models.model import Tag, TagBinding
from services.dataset_service import DatasetService, DocumentService
class DatasetRetrievalTestDataFactory:
"""Factory class for creating database-backed test data for dataset retrieval integration tests."""
@staticmethod
def create_account_with_tenant(role: TenantAccountRole = TenantAccountRole.NORMAL) -> tuple[Account, Tenant]:
"""Create an account and tenant with the specified role."""
account = Account(
email=f"{uuid4()}@example.com",
name=f"user-{uuid4()}",
interface_language="en-US",
status="active",
)
tenant = Tenant(
name=f"tenant-{uuid4()}",
status="normal",
)
db.session.add_all([account, tenant])
db.session.flush()
join = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
role=role,
current=True,
)
db.session.add(join)
db.session.commit()
account.current_tenant = tenant
return account, tenant
@staticmethod
def create_account_in_tenant(tenant: Tenant, role: TenantAccountRole = TenantAccountRole.OWNER) -> Account:
"""Create an account and add it to an existing tenant."""
account = Account(
email=f"{uuid4()}@example.com",
name=f"user-{uuid4()}",
interface_language="en-US",
status="active",
)
db.session.add(account)
db.session.flush()
join = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
role=role,
current=True,
)
db.session.add(join)
db.session.commit()
account.current_tenant = tenant
return account
@staticmethod
def create_dataset(
tenant_id: str,
created_by: str,
name: str = "Test Dataset",
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
) -> Dataset:
"""Create a dataset."""
dataset = Dataset(
tenant_id=tenant_id,
name=name,
description="desc",
data_source_type="upload_file",
indexing_technique="high_quality",
created_by=created_by,
permission=permission,
provider="vendor",
retrieval_model={"top_k": 2},
)
db.session.add(dataset)
db.session.commit()
return dataset
@staticmethod
def create_dataset_permission(dataset_id: str, tenant_id: str, account_id: str) -> DatasetPermission:
"""Create a dataset permission."""
permission = DatasetPermission(
dataset_id=dataset_id,
tenant_id=tenant_id,
account_id=account_id,
has_permission=True,
)
db.session.add(permission)
db.session.commit()
return permission
@staticmethod
def create_process_rule(dataset_id: str, created_by: str, mode: str, rules: dict) -> DatasetProcessRule:
"""Create a dataset process rule."""
process_rule = DatasetProcessRule(
dataset_id=dataset_id,
created_by=created_by,
mode=mode,
rules=json.dumps(rules),
)
db.session.add(process_rule)
db.session.commit()
return process_rule
@staticmethod
def create_dataset_query(dataset_id: str, created_by: str, content: str) -> DatasetQuery:
"""Create a dataset query."""
dataset_query = DatasetQuery(
dataset_id=dataset_id,
content=content,
source="web",
source_app_id=None,
created_by_role="account",
created_by=created_by,
)
db.session.add(dataset_query)
db.session.commit()
return dataset_query
@staticmethod
def create_app_dataset_join(dataset_id: str) -> AppDatasetJoin:
"""Create an app-dataset join."""
join = AppDatasetJoin(
app_id=str(uuid4()),
dataset_id=dataset_id,
)
db.session.add(join)
db.session.commit()
return join
@staticmethod
def create_tag_binding(tenant_id: str, created_by: str, target_id: str) -> Tag:
"""Create a knowledge tag and bind it to the target dataset."""
tag = Tag(
tenant_id=tenant_id,
type="knowledge",
name=f"tag-{uuid4()}",
created_by=created_by,
)
db.session.add(tag)
db.session.flush()
binding = TagBinding(
tenant_id=tenant_id,
tag_id=tag.id,
target_id=target_id,
created_by=created_by,
)
db.session.add(binding)
db.session.commit()
return tag
class TestDatasetServiceGetDatasets:
"""
Comprehensive integration tests for DatasetService.get_datasets method.
This test suite covers:
- Pagination
- Search functionality
- Tag filtering
- Permission-based filtering (ONLY_ME, ALL_TEAM, PARTIAL_TEAM)
- Role-based filtering (OWNER, DATASET_OPERATOR, NORMAL)
- include_all flag
"""
# ==================== Basic Retrieval Tests ====================
def test_get_datasets_basic_pagination(self, db_session_with_containers):
"""Test basic pagination without user or filters."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
page = 1
per_page = 20
for i in range(5):
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
name=f"Dataset {i}",
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id)
# Assert
assert len(datasets) == 5
assert total == 5
def test_get_datasets_with_search(self, db_session_with_containers):
"""Test get_datasets with search keyword."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
page = 1
per_page = 20
search = "test"
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
name="Test Dataset",
permission=DatasetPermissionEnum.ALL_TEAM,
)
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
name="Another Dataset",
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, search=search)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_with_tag_filtering(self, db_session_with_containers):
"""Test get_datasets with tag_ids filtering."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
page = 1
per_page = 20
dataset_1 = DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
dataset_2 = DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
tag_1 = DatasetRetrievalTestDataFactory.create_tag_binding(tenant.id, account.id, dataset_1.id)
tag_2 = DatasetRetrievalTestDataFactory.create_tag_binding(tenant.id, account.id, dataset_2.id)
tag_ids = [tag_1.id, tag_2.id]
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, tag_ids=tag_ids)
# Assert
assert len(datasets) == 2
assert total == 2
def test_get_datasets_with_empty_tag_ids(self, db_session_with_containers):
"""Test get_datasets with empty tag_ids skips tag filtering and returns all matching datasets."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
page = 1
per_page = 20
tag_ids = []
for i in range(3):
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
name=f"dataset-{i}",
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, tag_ids=tag_ids)
# Assert
# When tag_ids is empty, tag filtering is skipped, so normal query results are returned
assert len(datasets) == 3
assert total == 3
# ==================== Permission-Based Filtering Tests ====================
def test_get_datasets_without_user_shows_only_all_team(self, db_session_with_containers):
"""Test that without user, only ALL_TEAM datasets are shown."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
page = 1
per_page = 20
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=account.id,
permission=DatasetPermissionEnum.ONLY_ME,
)
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, user=None)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_owner_with_include_all(self, db_session_with_containers):
"""Test that OWNER with include_all=True sees all datasets."""
# Arrange
owner, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER)
for i, permission in enumerate(
[DatasetPermissionEnum.ONLY_ME, DatasetPermissionEnum.ALL_TEAM, DatasetPermissionEnum.PARTIAL_TEAM]
):
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=owner.id,
name=f"dataset-{i}",
permission=permission,
)
# Act
datasets, total = DatasetService.get_datasets(
page=1,
per_page=20,
tenant_id=tenant.id,
user=owner,
include_all=True,
)
# Assert
assert len(datasets) == 3
assert total == 3
def test_get_datasets_normal_user_only_me_permission(self, db_session_with_containers):
"""Test that normal user sees ONLY_ME datasets they created."""
# Arrange
user, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=user.id,
permission=DatasetPermissionEnum.ONLY_ME,
)
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_normal_user_all_team_permission(self, db_session_with_containers):
"""Test that normal user sees ALL_TEAM datasets."""
# Arrange
user, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
owner = DatasetRetrievalTestDataFactory.create_account_in_tenant(tenant, role=TenantAccountRole.OWNER)
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=owner.id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_normal_user_partial_team_with_permission(self, db_session_with_containers):
"""Test that normal user sees PARTIAL_TEAM datasets they have permission for."""
# Arrange
user, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL)
owner = DatasetRetrievalTestDataFactory.create_account_in_tenant(tenant, role=TenantAccountRole.OWNER)
dataset = DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=owner.id,
permission=DatasetPermissionEnum.PARTIAL_TEAM,
)
DatasetRetrievalTestDataFactory.create_dataset_permission(dataset.id, tenant.id, user.id)
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_dataset_operator_with_permissions(self, db_session_with_containers):
"""Test that DATASET_OPERATOR only sees datasets they have explicit permission for."""
# Arrange
operator, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant(
role=TenantAccountRole.DATASET_OPERATOR
)
owner = DatasetRetrievalTestDataFactory.create_account_in_tenant(tenant, role=TenantAccountRole.OWNER)
dataset = DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=owner.id,
permission=DatasetPermissionEnum.ONLY_ME,
)
DatasetRetrievalTestDataFactory.create_dataset_permission(dataset.id, tenant.id, operator.id)
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=operator)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_dataset_operator_without_permissions(self, db_session_with_containers):
"""Test that DATASET_OPERATOR without permissions returns empty result."""
# Arrange
operator, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant(
role=TenantAccountRole.DATASET_OPERATOR
)
owner = DatasetRetrievalTestDataFactory.create_account_in_tenant(tenant, role=TenantAccountRole.OWNER)
DatasetRetrievalTestDataFactory.create_dataset(
tenant_id=tenant.id,
created_by=owner.id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=operator)
# Assert
assert datasets == []
assert total == 0
class TestDatasetServiceGetDataset:
"""Comprehensive integration tests for DatasetService.get_dataset method."""
def test_get_dataset_success(self, db_session_with_containers):
"""Test successful retrieval of a single dataset."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
# Act
result = DatasetService.get_dataset(dataset.id)
# Assert
assert result is not None
assert result.id == dataset.id
def test_get_dataset_not_found(self, db_session_with_containers):
"""Test retrieval when dataset doesn't exist."""
# Arrange
dataset_id = str(uuid4())
# Act
result = DatasetService.get_dataset(dataset_id)
# Assert
assert result is None
class TestDatasetServiceGetDatasetsByIds:
"""Comprehensive integration tests for DatasetService.get_datasets_by_ids method."""
def test_get_datasets_by_ids_success(self, db_session_with_containers):
"""Test successful bulk retrieval of datasets by IDs."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
datasets = [
DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id) for _ in range(3)
]
dataset_ids = [dataset.id for dataset in datasets]
# Act
result_datasets, total = DatasetService.get_datasets_by_ids(dataset_ids, tenant.id)
# Assert
assert len(result_datasets) == 3
assert total == 3
assert all(dataset.id in dataset_ids for dataset in result_datasets)
def test_get_datasets_by_ids_empty_list(self, db_session_with_containers):
"""Test get_datasets_by_ids with empty list returns empty result."""
# Arrange
tenant_id = str(uuid4())
dataset_ids = []
# Act
datasets, total = DatasetService.get_datasets_by_ids(dataset_ids, tenant_id)
# Assert
assert datasets == []
assert total == 0
def test_get_datasets_by_ids_none_list(self, db_session_with_containers):
"""Test get_datasets_by_ids with None returns empty result."""
# Arrange
tenant_id = str(uuid4())
# Act
datasets, total = DatasetService.get_datasets_by_ids(None, tenant_id)
# Assert
assert datasets == []
assert total == 0
class TestDatasetServiceGetProcessRules:
"""Comprehensive integration tests for DatasetService.get_process_rules method."""
def test_get_process_rules_with_existing_rule(self, db_session_with_containers):
"""Test retrieval of process rules when rule exists."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
rules_data = {
"pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": True}],
"segmentation": {"delimiter": "\n", "max_tokens": 500},
}
DatasetRetrievalTestDataFactory.create_process_rule(
dataset_id=dataset.id,
created_by=account.id,
mode="custom",
rules=rules_data,
)
# Act
result = DatasetService.get_process_rules(dataset.id)
# Assert
assert result["mode"] == "custom"
assert result["rules"] == rules_data
def test_get_process_rules_without_existing_rule(self, db_session_with_containers):
"""Test retrieval of process rules when no rule exists (returns defaults)."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
# Act
result = DatasetService.get_process_rules(dataset.id)
# Assert
assert result["mode"] == DocumentService.DEFAULT_RULES["mode"]
assert "rules" in result
assert result["rules"] == DocumentService.DEFAULT_RULES["rules"]
class TestDatasetServiceGetDatasetQueries:
"""Comprehensive integration tests for DatasetService.get_dataset_queries method."""
def test_get_dataset_queries_success(self, db_session_with_containers):
"""Test successful retrieval of dataset queries."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
page = 1
per_page = 20
for i in range(3):
DatasetRetrievalTestDataFactory.create_dataset_query(
dataset_id=dataset.id,
created_by=account.id,
content=f"query-{i}",
)
# Act
queries, total = DatasetService.get_dataset_queries(dataset.id, page, per_page)
# Assert
assert len(queries) == 3
assert total == 3
assert all(query.dataset_id == dataset.id for query in queries)
def test_get_dataset_queries_empty_result(self, db_session_with_containers):
"""Test retrieval when no queries exist."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
page = 1
per_page = 20
# Act
queries, total = DatasetService.get_dataset_queries(dataset.id, page, per_page)
# Assert
assert queries == []
assert total == 0
class TestDatasetServiceGetRelatedApps:
"""Comprehensive integration tests for DatasetService.get_related_apps method."""
def test_get_related_apps_success(self, db_session_with_containers):
"""Test successful retrieval of related apps."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
for _ in range(2):
DatasetRetrievalTestDataFactory.create_app_dataset_join(dataset.id)
# Act
result = DatasetService.get_related_apps(dataset.id)
# Assert
assert len(result) == 2
assert all(join.dataset_id == dataset.id for join in result)
def test_get_related_apps_empty_result(self, db_session_with_containers):
"""Test retrieval when no related apps exist."""
# Arrange
account, tenant = DatasetRetrievalTestDataFactory.create_account_with_tenant()
dataset = DatasetRetrievalTestDataFactory.create_dataset(tenant_id=tenant.id, created_by=account.id)
# Act
result = DatasetService.get_related_apps(dataset.id)
# Assert
assert result == []

View File

@@ -1,746 +0,0 @@
"""
Comprehensive unit tests for DatasetService retrieval/list methods.
This test suite covers:
- get_datasets - pagination, search, filtering, permissions
- get_dataset - single dataset retrieval
- get_datasets_by_ids - bulk retrieval
- get_process_rules - dataset processing rules
- get_dataset_queries - dataset query history
- get_related_apps - apps using the dataset
"""
from unittest.mock import Mock, create_autospec, patch
from uuid import uuid4
import pytest
from models.account import Account, TenantAccountRole
from models.dataset import (
AppDatasetJoin,
Dataset,
DatasetPermission,
DatasetPermissionEnum,
DatasetProcessRule,
DatasetQuery,
)
from services.dataset_service import DatasetService, DocumentService
class DatasetRetrievalTestDataFactory:
"""Factory class for creating test data and mock objects for dataset retrieval tests."""
@staticmethod
def create_dataset_mock(
dataset_id: str = "dataset-123",
name: str = "Test Dataset",
tenant_id: str = "tenant-123",
created_by: str = "user-123",
permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
**kwargs,
) -> Mock:
"""Create a mock dataset with specified attributes."""
dataset = Mock(spec=Dataset)
dataset.id = dataset_id
dataset.name = name
dataset.tenant_id = tenant_id
dataset.created_by = created_by
dataset.permission = permission
for key, value in kwargs.items():
setattr(dataset, key, value)
return dataset
@staticmethod
def create_account_mock(
account_id: str = "account-123",
tenant_id: str = "tenant-123",
role: TenantAccountRole = TenantAccountRole.NORMAL,
**kwargs,
) -> Mock:
"""Create a mock account."""
account = create_autospec(Account, instance=True)
account.id = account_id
account.current_tenant_id = tenant_id
account.current_role = role
for key, value in kwargs.items():
setattr(account, key, value)
return account
@staticmethod
def create_dataset_permission_mock(
dataset_id: str = "dataset-123",
account_id: str = "account-123",
**kwargs,
) -> Mock:
"""Create a mock dataset permission."""
permission = Mock(spec=DatasetPermission)
permission.dataset_id = dataset_id
permission.account_id = account_id
for key, value in kwargs.items():
setattr(permission, key, value)
return permission
@staticmethod
def create_process_rule_mock(
dataset_id: str = "dataset-123",
mode: str = "automatic",
rules: dict | None = None,
**kwargs,
) -> Mock:
"""Create a mock dataset process rule."""
process_rule = Mock(spec=DatasetProcessRule)
process_rule.dataset_id = dataset_id
process_rule.mode = mode
process_rule.rules_dict = rules or {}
for key, value in kwargs.items():
setattr(process_rule, key, value)
return process_rule
@staticmethod
def create_dataset_query_mock(
dataset_id: str = "dataset-123",
query_id: str = "query-123",
**kwargs,
) -> Mock:
"""Create a mock dataset query."""
dataset_query = Mock(spec=DatasetQuery)
dataset_query.id = query_id
dataset_query.dataset_id = dataset_id
for key, value in kwargs.items():
setattr(dataset_query, key, value)
return dataset_query
@staticmethod
def create_app_dataset_join_mock(
app_id: str = "app-123",
dataset_id: str = "dataset-123",
**kwargs,
) -> Mock:
"""Create a mock app-dataset join."""
join = Mock(spec=AppDatasetJoin)
join.app_id = app_id
join.dataset_id = dataset_id
for key, value in kwargs.items():
setattr(join, key, value)
return join
class TestDatasetServiceGetDatasets:
"""
Comprehensive unit tests for DatasetService.get_datasets method.
This test suite covers:
- Pagination
- Search functionality
- Tag filtering
- Permission-based filtering (ONLY_ME, ALL_TEAM, PARTIAL_TEAM)
- Role-based filtering (OWNER, DATASET_OPERATOR, NORMAL)
- include_all flag
"""
@pytest.fixture
def mock_dependencies(self):
"""Common mock setup for get_datasets tests."""
with (
patch("services.dataset_service.db.session") as mock_db,
patch("services.dataset_service.db.paginate") as mock_paginate,
patch("services.dataset_service.TagService") as mock_tag_service,
):
yield {
"db_session": mock_db,
"paginate": mock_paginate,
"tag_service": mock_tag_service,
}
# ==================== Basic Retrieval Tests ====================
def test_get_datasets_basic_pagination(self, mock_dependencies):
"""Test basic pagination without user or filters."""
# Arrange
tenant_id = str(uuid4())
page = 1
per_page = 20
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(
dataset_id=f"dataset-{i}", name=f"Dataset {i}", tenant_id=tenant_id
)
for i in range(5)
]
mock_paginate_result.total = 5
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant_id)
# Assert
assert len(datasets) == 5
assert total == 5
mock_dependencies["paginate"].assert_called_once()
def test_get_datasets_with_search(self, mock_dependencies):
"""Test get_datasets with search keyword."""
# Arrange
tenant_id = str(uuid4())
page = 1
per_page = 20
search = "test"
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(
dataset_id="dataset-1", name="Test Dataset", tenant_id=tenant_id
)
]
mock_paginate_result.total = 1
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant_id, search=search)
# Assert
assert len(datasets) == 1
assert total == 1
mock_dependencies["paginate"].assert_called_once()
def test_get_datasets_with_tag_filtering(self, mock_dependencies):
"""Test get_datasets with tag_ids filtering."""
# Arrange
tenant_id = str(uuid4())
page = 1
per_page = 20
tag_ids = ["tag-1", "tag-2"]
# Mock tag service
target_ids = ["dataset-1", "dataset-2"]
mock_dependencies["tag_service"].get_target_ids_by_tag_ids.return_value = target_ids
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(dataset_id=dataset_id, tenant_id=tenant_id)
for dataset_id in target_ids
]
mock_paginate_result.total = 2
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant_id, tag_ids=tag_ids)
# Assert
assert len(datasets) == 2
assert total == 2
mock_dependencies["tag_service"].get_target_ids_by_tag_ids.assert_called_once_with(
"knowledge", tenant_id, tag_ids
)
def test_get_datasets_with_empty_tag_ids(self, mock_dependencies):
"""Test get_datasets with empty tag_ids skips tag filtering and returns all matching datasets."""
# Arrange
tenant_id = str(uuid4())
page = 1
per_page = 20
tag_ids = []
# Mock pagination result - when tag_ids is empty, tag filtering is skipped
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(dataset_id=f"dataset-{i}", tenant_id=tenant_id)
for i in range(3)
]
mock_paginate_result.total = 3
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant_id, tag_ids=tag_ids)
# Assert
# When tag_ids is empty, tag filtering is skipped, so normal query results are returned
assert len(datasets) == 3
assert total == 3
# Tag service should not be called when tag_ids is empty
mock_dependencies["tag_service"].get_target_ids_by_tag_ids.assert_not_called()
mock_dependencies["paginate"].assert_called_once()
# ==================== Permission-Based Filtering Tests ====================
def test_get_datasets_without_user_shows_only_all_team(self, mock_dependencies):
"""Test that without user, only ALL_TEAM datasets are shown."""
# Arrange
tenant_id = str(uuid4())
page = 1
per_page = 20
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(
dataset_id="dataset-1",
tenant_id=tenant_id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
]
mock_paginate_result.total = 1
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant_id, user=None)
# Assert
assert len(datasets) == 1
mock_dependencies["paginate"].assert_called_once()
def test_get_datasets_owner_with_include_all(self, mock_dependencies):
"""Test that OWNER with include_all=True sees all datasets."""
# Arrange
tenant_id = str(uuid4())
user = DatasetRetrievalTestDataFactory.create_account_mock(
account_id="owner-123", tenant_id=tenant_id, role=TenantAccountRole.OWNER
)
# Mock dataset permissions query (empty - owner doesn't need explicit permissions)
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = []
mock_dependencies["db_session"].query.return_value = mock_query
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(dataset_id=f"dataset-{i}", tenant_id=tenant_id)
for i in range(3)
]
mock_paginate_result.total = 3
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(
page=1, per_page=20, tenant_id=tenant_id, user=user, include_all=True
)
# Assert
assert len(datasets) == 3
assert total == 3
def test_get_datasets_normal_user_only_me_permission(self, mock_dependencies):
"""Test that normal user sees ONLY_ME datasets they created."""
# Arrange
tenant_id = str(uuid4())
user_id = "user-123"
user = DatasetRetrievalTestDataFactory.create_account_mock(
account_id=user_id, tenant_id=tenant_id, role=TenantAccountRole.NORMAL
)
# Mock dataset permissions query (no explicit permissions)
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = []
mock_dependencies["db_session"].query.return_value = mock_query
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(
dataset_id="dataset-1",
tenant_id=tenant_id,
created_by=user_id,
permission=DatasetPermissionEnum.ONLY_ME,
)
]
mock_paginate_result.total = 1
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant_id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_normal_user_all_team_permission(self, mock_dependencies):
"""Test that normal user sees ALL_TEAM datasets."""
# Arrange
tenant_id = str(uuid4())
user = DatasetRetrievalTestDataFactory.create_account_mock(
account_id="user-123", tenant_id=tenant_id, role=TenantAccountRole.NORMAL
)
# Mock dataset permissions query (no explicit permissions)
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = []
mock_dependencies["db_session"].query.return_value = mock_query
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(
dataset_id="dataset-1",
tenant_id=tenant_id,
permission=DatasetPermissionEnum.ALL_TEAM,
)
]
mock_paginate_result.total = 1
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant_id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_normal_user_partial_team_with_permission(self, mock_dependencies):
"""Test that normal user sees PARTIAL_TEAM datasets they have permission for."""
# Arrange
tenant_id = str(uuid4())
user_id = "user-123"
dataset_id = "dataset-1"
user = DatasetRetrievalTestDataFactory.create_account_mock(
account_id=user_id, tenant_id=tenant_id, role=TenantAccountRole.NORMAL
)
# Mock dataset permissions query - user has permission
permission = DatasetRetrievalTestDataFactory.create_dataset_permission_mock(
dataset_id=dataset_id, account_id=user_id
)
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = [permission]
mock_dependencies["db_session"].query.return_value = mock_query
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(
dataset_id=dataset_id,
tenant_id=tenant_id,
permission=DatasetPermissionEnum.PARTIAL_TEAM,
)
]
mock_paginate_result.total = 1
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant_id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_dataset_operator_with_permissions(self, mock_dependencies):
"""Test that DATASET_OPERATOR only sees datasets they have explicit permission for."""
# Arrange
tenant_id = str(uuid4())
user_id = "operator-123"
dataset_id = "dataset-1"
user = DatasetRetrievalTestDataFactory.create_account_mock(
account_id=user_id, tenant_id=tenant_id, role=TenantAccountRole.DATASET_OPERATOR
)
# Mock dataset permissions query - operator has permission
permission = DatasetRetrievalTestDataFactory.create_dataset_permission_mock(
dataset_id=dataset_id, account_id=user_id
)
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = [permission]
mock_dependencies["db_session"].query.return_value = mock_query
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(dataset_id=dataset_id, tenant_id=tenant_id)
]
mock_paginate_result.total = 1
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant_id, user=user)
# Assert
assert len(datasets) == 1
assert total == 1
def test_get_datasets_dataset_operator_without_permissions(self, mock_dependencies):
"""Test that DATASET_OPERATOR without permissions returns empty result."""
# Arrange
tenant_id = str(uuid4())
user_id = "operator-123"
user = DatasetRetrievalTestDataFactory.create_account_mock(
account_id=user_id, tenant_id=tenant_id, role=TenantAccountRole.DATASET_OPERATOR
)
# Mock dataset permissions query - no permissions
mock_query = Mock()
mock_query.filter_by.return_value.all.return_value = []
mock_dependencies["db_session"].query.return_value = mock_query
# Act
datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant_id, user=user)
# Assert
assert datasets == []
assert total == 0
class TestDatasetServiceGetDataset:
"""Comprehensive unit tests for DatasetService.get_dataset method."""
@pytest.fixture
def mock_dependencies(self):
"""Common mock setup for get_dataset tests."""
with patch("services.dataset_service.db.session") as mock_db:
yield {"db_session": mock_db}
def test_get_dataset_success(self, mock_dependencies):
"""Test successful retrieval of a single dataset."""
# Arrange
dataset_id = str(uuid4())
dataset = DatasetRetrievalTestDataFactory.create_dataset_mock(dataset_id=dataset_id)
# Mock database query
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = dataset
mock_dependencies["db_session"].query.return_value = mock_query
# Act
result = DatasetService.get_dataset(dataset_id)
# Assert
assert result is not None
assert result.id == dataset_id
mock_query.filter_by.assert_called_once_with(id=dataset_id)
def test_get_dataset_not_found(self, mock_dependencies):
"""Test retrieval when dataset doesn't exist."""
# Arrange
dataset_id = str(uuid4())
# Mock database query returning None
mock_query = Mock()
mock_query.filter_by.return_value.first.return_value = None
mock_dependencies["db_session"].query.return_value = mock_query
# Act
result = DatasetService.get_dataset(dataset_id)
# Assert
assert result is None
class TestDatasetServiceGetDatasetsByIds:
"""Comprehensive unit tests for DatasetService.get_datasets_by_ids method."""
@pytest.fixture
def mock_dependencies(self):
"""Common mock setup for get_datasets_by_ids tests."""
with patch("services.dataset_service.db.paginate") as mock_paginate:
yield {"paginate": mock_paginate}
def test_get_datasets_by_ids_success(self, mock_dependencies):
"""Test successful bulk retrieval of datasets by IDs."""
# Arrange
tenant_id = str(uuid4())
dataset_ids = [str(uuid4()), str(uuid4()), str(uuid4())]
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_mock(dataset_id=dataset_id, tenant_id=tenant_id)
for dataset_id in dataset_ids
]
mock_paginate_result.total = len(dataset_ids)
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
datasets, total = DatasetService.get_datasets_by_ids(dataset_ids, tenant_id)
# Assert
assert len(datasets) == 3
assert total == 3
assert all(dataset.id in dataset_ids for dataset in datasets)
mock_dependencies["paginate"].assert_called_once()
def test_get_datasets_by_ids_empty_list(self, mock_dependencies):
"""Test get_datasets_by_ids with empty list returns empty result."""
# Arrange
tenant_id = str(uuid4())
dataset_ids = []
# Act
datasets, total = DatasetService.get_datasets_by_ids(dataset_ids, tenant_id)
# Assert
assert datasets == []
assert total == 0
mock_dependencies["paginate"].assert_not_called()
def test_get_datasets_by_ids_none_list(self, mock_dependencies):
"""Test get_datasets_by_ids with None returns empty result."""
# Arrange
tenant_id = str(uuid4())
# Act
datasets, total = DatasetService.get_datasets_by_ids(None, tenant_id)
# Assert
assert datasets == []
assert total == 0
mock_dependencies["paginate"].assert_not_called()
class TestDatasetServiceGetProcessRules:
"""Comprehensive unit tests for DatasetService.get_process_rules method."""
@pytest.fixture
def mock_dependencies(self):
"""Common mock setup for get_process_rules tests."""
with patch("services.dataset_service.db.session") as mock_db:
yield {"db_session": mock_db}
def test_get_process_rules_with_existing_rule(self, mock_dependencies):
"""Test retrieval of process rules when rule exists."""
# Arrange
dataset_id = str(uuid4())
rules_data = {
"pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": True}],
"segmentation": {"delimiter": "\n", "max_tokens": 500},
}
process_rule = DatasetRetrievalTestDataFactory.create_process_rule_mock(
dataset_id=dataset_id, mode="custom", rules=rules_data
)
# Mock database query
mock_query = Mock()
mock_query.where.return_value.order_by.return_value.limit.return_value.one_or_none.return_value = process_rule
mock_dependencies["db_session"].query.return_value = mock_query
# Act
result = DatasetService.get_process_rules(dataset_id)
# Assert
assert result["mode"] == "custom"
assert result["rules"] == rules_data
def test_get_process_rules_without_existing_rule(self, mock_dependencies):
"""Test retrieval of process rules when no rule exists (returns defaults)."""
# Arrange
dataset_id = str(uuid4())
# Mock database query returning None
mock_query = Mock()
mock_query.where.return_value.order_by.return_value.limit.return_value.one_or_none.return_value = None
mock_dependencies["db_session"].query.return_value = mock_query
# Act
result = DatasetService.get_process_rules(dataset_id)
# Assert
assert result["mode"] == DocumentService.DEFAULT_RULES["mode"]
assert "rules" in result
assert result["rules"] == DocumentService.DEFAULT_RULES["rules"]
class TestDatasetServiceGetDatasetQueries:
"""Comprehensive unit tests for DatasetService.get_dataset_queries method."""
@pytest.fixture
def mock_dependencies(self):
"""Common mock setup for get_dataset_queries tests."""
with patch("services.dataset_service.db.paginate") as mock_paginate:
yield {"paginate": mock_paginate}
def test_get_dataset_queries_success(self, mock_dependencies):
"""Test successful retrieval of dataset queries."""
# Arrange
dataset_id = str(uuid4())
page = 1
per_page = 20
# Mock pagination result
mock_paginate_result = Mock()
mock_paginate_result.items = [
DatasetRetrievalTestDataFactory.create_dataset_query_mock(dataset_id=dataset_id, query_id=f"query-{i}")
for i in range(3)
]
mock_paginate_result.total = 3
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
queries, total = DatasetService.get_dataset_queries(dataset_id, page, per_page)
# Assert
assert len(queries) == 3
assert total == 3
assert all(query.dataset_id == dataset_id for query in queries)
mock_dependencies["paginate"].assert_called_once()
def test_get_dataset_queries_empty_result(self, mock_dependencies):
"""Test retrieval when no queries exist."""
# Arrange
dataset_id = str(uuid4())
page = 1
per_page = 20
# Mock pagination result (empty)
mock_paginate_result = Mock()
mock_paginate_result.items = []
mock_paginate_result.total = 0
mock_dependencies["paginate"].return_value = mock_paginate_result
# Act
queries, total = DatasetService.get_dataset_queries(dataset_id, page, per_page)
# Assert
assert queries == []
assert total == 0
class TestDatasetServiceGetRelatedApps:
"""Comprehensive unit tests for DatasetService.get_related_apps method."""
@pytest.fixture
def mock_dependencies(self):
"""Common mock setup for get_related_apps tests."""
with patch("services.dataset_service.db.session") as mock_db:
yield {"db_session": mock_db}
def test_get_related_apps_success(self, mock_dependencies):
"""Test successful retrieval of related apps."""
# Arrange
dataset_id = str(uuid4())
# Mock app-dataset joins
app_joins = [
DatasetRetrievalTestDataFactory.create_app_dataset_join_mock(app_id=f"app-{i}", dataset_id=dataset_id)
for i in range(2)
]
# Mock database query
mock_query = Mock()
mock_query.where.return_value.order_by.return_value.all.return_value = app_joins
mock_dependencies["db_session"].query.return_value = mock_query
# Act
result = DatasetService.get_related_apps(dataset_id)
# Assert
assert len(result) == 2
assert all(join.dataset_id == dataset_id for join in result)
mock_query.where.assert_called_once()
mock_query.where.return_value.order_by.assert_called_once()
def test_get_related_apps_empty_result(self, mock_dependencies):
"""Test retrieval when no related apps exist."""
# Arrange
dataset_id = str(uuid4())
# Mock database query returning empty list
mock_query = Mock()
mock_query.where.return_value.order_by.return_value.all.return_value = []
mock_dependencies["db_session"].query.return_value = mock_query
# Act
result = DatasetService.get_related_apps(dataset_id)
# Assert
assert result == []

6
api/uv.lock generated
View File

@@ -5047,11 +5047,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.7.1"
version = "6.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/63/3437c4363483f2a04000a48f1cd48c40097f69d580363712fa8b0b4afe45/pypdf-6.7.1.tar.gz", hash = "sha256:6b7a63be5563a0a35d54c6d6b550d75c00b8ccf36384be96365355e296e6b3b0", size = 5302208, upload-time = "2026-02-17T17:00:48.88Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b2/335465d6cff28a772ace8a58beb168f125c2e1d8f7a31527da180f4d89a1/pypdf-6.7.2.tar.gz", hash = "sha256:82a1a48de500ceea59a52a7d979f5095927ef802e4e4fac25ab862a73468acbb", size = 5302986, upload-time = "2026-02-22T11:33:30.776Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/77/38bd7744bb9e06d465b0c23879e6d2c187d93a383f8fa485c862822bb8a3/pypdf-6.7.1-py3-none-any.whl", hash = "sha256:a02ccbb06463f7c334ce1612e91b3e68a8e827f3cee100b9941771e6066b094e", size = 331048, upload-time = "2026-02-17T17:00:46.991Z" },
{ url = "https://files.pythonhosted.org/packages/df/df/38b06d6e74646a4281856920a11efb431559bdeb643bf1e192bff5e29082/pypdf-6.7.2-py3-none-any.whl", hash = "sha256:331b63cd66f63138f152a700565b3e0cebdf4ec8bec3b7594b2522418782f1f3", size = 331245, upload-time = "2026-02-22T11:33:29.204Z" },
]
[[package]]

View File

@@ -0,0 +1,394 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import useThemeMock from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import AudioPlayer from './AudioPlayer'
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(() => ({ theme: 'light' })),
}))
// ─── Helpers ──────────────────────────────────────────────────────────────────
function buildAudioContext(channelLength = 512) {
return class MockAudioContext {
decodeAudioData(_ab: ArrayBuffer) {
const arr = new Float32Array(channelLength)
for (let i = 0; i < channelLength; i++)
arr[i] = Math.sin((i / channelLength) * Math.PI * 2) * 0.5
return Promise.resolve({ getChannelData: (_ch: number) => arr })
}
close() { return Promise.resolve() }
}
}
function stubFetchOk(size = 256) {
const ab = new ArrayBuffer(size)
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
arrayBuffer: async () => ab,
} as Response)
}
function stubFetchFail() {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as Response)
}
async function advanceWaveformTimer() {
await act(async () => {
vi.advanceTimersByTime(1000)
await Promise.resolve()
await Promise.resolve()
})
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.light })
HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined)
HTMLMediaElement.prototype.pause = vi.fn()
HTMLMediaElement.prototype.load = vi.fn()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('AudioPlayer — rendering', () => {
it('should render the play button and audio element when given a src', () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
expect(screen.getByTestId('play-pause-btn')).toBeInTheDocument()
expect(document.querySelector('audio')).toBeInTheDocument()
expect(document.querySelector('audio')?.getAttribute('src')).toBe('https://example.com/a.mp3')
})
it('should render <source> elements when srcs array is provided', () => {
render(<AudioPlayer srcs={['https://example.com/a.mp3', 'https://example.com/b.ogg']} />)
const sources = document.querySelectorAll('audio source')
expect(sources).toHaveLength(2)
expect((sources[0] as HTMLSourceElement).src).toBe('https://example.com/a.mp3')
expect((sources[1] as HTMLSourceElement).src).toBe('https://example.com/b.ogg')
})
it('should render without crashing when no props are supplied', () => {
render(<AudioPlayer />)
expect(screen.getByTestId('play-pause-btn')).toBeInTheDocument()
})
})
// ─── Play / Pause toggle ──────────────────────────────────────────────────────
describe('AudioPlayer — play/pause', () => {
it('should call audio.play() on first button click', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const btn = screen.getByTestId('play-pause-btn')
await act(async () => {
fireEvent.click(btn)
})
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1)
})
it('should call audio.pause() on second button click', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const btn = screen.getByTestId('play-pause-btn')
await act(async () => {
fireEvent.click(btn)
})
await act(async () => {
fireEvent.click(btn)
})
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1)
})
it('should show the pause icon while playing and play icon while paused', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const btn = screen.getByTestId('play-pause-btn')
expect(btn.querySelector('.i-ri-play-large-fill')).toBeInTheDocument()
expect(btn.querySelector('.i-ri-pause-circle-fill')).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(btn)
})
expect(btn.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument()
expect(btn.querySelector('.i-ri-play-large-fill')).not.toBeInTheDocument()
})
it('should reset to stopped state when the audio ends', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const btn = screen.getByTestId('play-pause-btn')
await act(async () => {
fireEvent.click(btn)
})
expect(btn.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument()
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
audio.dispatchEvent(new Event('ended'))
})
expect(btn.querySelector('.i-ri-play-large-fill')).toBeInTheDocument()
})
it('should disable the play button when an audio error occurs', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
audio.dispatchEvent(new Event('error'))
})
expect(screen.getByTestId('play-pause-btn')).toBeDisabled()
})
})
// ─── Audio events ─────────────────────────────────────────────────────────────
describe('AudioPlayer — audio events', () => {
it('should update duration display when loadedmetadata fires', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: 90, configurable: true })
await act(async () => {
audio.dispatchEvent(new Event('loadedmetadata'))
})
expect(screen.getByText('1:30')).toBeInTheDocument()
})
it('should update bufferedTime on progress event', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
const bufferedStub = { length: 1, start: () => 0, end: () => 60 }
Object.defineProperty(audio, 'buffered', { value: bufferedStub, configurable: true })
await act(async () => {
audio.dispatchEvent(new Event('progress'))
})
})
it('should do nothing on progress when buffered.length is 0', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
const bufferedStub = { length: 0, start: () => 0, end: () => 0 }
Object.defineProperty(audio, 'buffered', { value: bufferedStub, configurable: true })
await act(async () => {
audio.dispatchEvent(new Event('progress'))
})
})
it('should set isAudioAvailable to false when an audio error occurs', async () => {
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
audio.dispatchEvent(new Event('error'))
})
expect(screen.getByTestId('play-pause-btn')).toBeDisabled()
})
})
// ─── Waveform generation ──────────────────────────────────────────────────────
describe('AudioPlayer — waveform generation', () => {
it('should render the waveform canvas after fetch + decode succeed', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(700))
stubFetchOk(512)
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
await advanceWaveformTimer()
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
it('should use fallback random waveform when fetch returns not-ok', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(400))
stubFetchFail()
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
await advanceWaveformTimer()
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
it('should use fallback waveform when decodeAudioData rejects', async () => {
class FailDecodeContext {
decodeAudioData() { return Promise.reject(new Error('decode error')) }
close() { return Promise.resolve() }
}
vi.stubGlobal('AudioContext', FailDecodeContext)
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(128),
} as Response)
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
await advanceWaveformTimer()
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
it('should show Toast when AudioContext is not available', async () => {
vi.stubGlobal('AudioContext', undefined)
render(<AudioPlayer src="https://example.com/audio.mp3" />)
await advanceWaveformTimer()
const toastFound = Array.from(document.body.querySelectorAll('div')).some(
d => d.textContent?.includes('Web Audio API is not supported in this browser'),
)
expect(toastFound).toBe(true)
})
it('should set audio unavailable when URL is not http/https', async () => {
vi.stubGlobal('AudioContext', buildAudioContext())
render(<AudioPlayer srcs={['blob:something']} />)
await advanceWaveformTimer()
expect(screen.getByTestId('play-pause-btn')).toBeDisabled()
})
it('should not trigger waveform generation when no src or srcs provided', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
render(<AudioPlayer />)
await advanceWaveformTimer()
expect(fetchSpy).not.toHaveBeenCalled()
})
it('should use srcs[0] as primary source for waveform', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
const fetchSpy = stubFetchOk(256)
render(<AudioPlayer srcs={['https://cdn.example/first.mp3', 'https://cdn.example/second.mp3']} />)
await advanceWaveformTimer()
expect(fetchSpy).toHaveBeenCalledWith('https://cdn.example/first.mp3', { mode: 'cors' })
})
it('should cover dark theme waveform draw branch', async () => {
; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark })
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(256)
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
await advanceWaveformTimer()
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
})
})
// ─── Canvas interactions ──────────────────────────────────────────────────────
describe('AudioPlayer — canvas seek interactions', () => {
async function renderWithDuration(src = 'https://example.com/audio.mp3', durationVal = 120) {
vi.stubGlobal('AudioContext', buildAudioContext(300))
stubFetchOk(128)
render(<AudioPlayer src={src} />)
const audio = document.querySelector('audio') as HTMLAudioElement
Object.defineProperty(audio, 'duration', { value: durationVal, configurable: true })
Object.defineProperty(audio, 'buffered', {
value: { length: 1, start: () => 0, end: () => durationVal },
configurable: true,
})
await act(async () => {
audio.dispatchEvent(new Event('loadedmetadata'))
})
await advanceWaveformTimer()
const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement
canvas.getBoundingClientRect = () =>
({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect
return { audio, canvas }
}
it('should seek to clicked position and start playback', async () => {
const { audio, canvas } = await renderWithDuration()
await act(async () => {
fireEvent.click(canvas, { clientX: 100 })
})
expect(Math.abs((audio.currentTime || 0) - 60)).toBeLessThanOrEqual(2)
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled()
})
it('should seek on mousedown', async () => {
const { canvas } = await renderWithDuration()
await act(async () => {
fireEvent.mouseDown(canvas, { clientX: 50 })
})
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled()
})
it('should not call play again when already playing and canvas is clicked', async () => {
const { canvas } = await renderWithDuration()
await act(async () => {
fireEvent.click(canvas, { clientX: 50 })
})
const callsAfterFirst = (HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length
await act(async () => {
fireEvent.click(canvas, { clientX: 80 })
})
expect((HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsAfterFirst)
})
it('should update hoverTime on mousemove within buffered range', async () => {
const { audio, canvas } = await renderWithDuration()
Object.defineProperty(audio, 'buffered', {
value: { length: 1, start: () => 0, end: () => 120 },
configurable: true,
})
await act(async () => {
fireEvent.mouseMove(canvas, { clientX: 100 })
})
})
it('should not update hoverTime when outside all buffered ranges', async () => {
const { audio, canvas } = await renderWithDuration()
Object.defineProperty(audio, 'buffered', {
value: { length: 0, start: () => 0, end: () => 0 },
configurable: true,
})
await act(async () => {
fireEvent.mouseMove(canvas, { clientX: 100 })
})
})
})

View File

@@ -1,7 +1,3 @@
import {
RiPauseCircleFill,
RiPlayLargeFill,
} from '@remixicon/react'
import { t } from 'i18next'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -299,25 +295,26 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
<source key={index} src={srcUrl} />
))}
</audio>
<button type="button" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
<button type="button" data-testid="play-pause-btn" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
{isPlaying
? (
<RiPauseCircleFill className="h-5 w-5" />
<div className="i-ri-pause-circle-fill h-5 w-5" />
)
: (
<RiPlayLargeFill className="h-5 w-5" />
<div className="i-ri-play-large-fill h-5 w-5" />
)}
</button>
<div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}>
<div className="flex h-8 items-center justify-center">
<canvas
ref={canvasRef}
data-testid="waveform-canvas"
className="relative flex h-6 w-full grow cursor-pointer items-center justify-center"
onClick={handleCanvasInteraction}
onMouseMove={handleMouseMove}
onMouseDown={handleCanvasInteraction}
/>
<div className="system-xs-medium inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary">
<div className="inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary system-xs-medium">
<span className="rounded-[10px] px-0.5 py-1">{formatTime(duration)}</span>
</div>
</div>

View File

@@ -37,5 +37,5 @@
}
.spin-animation path:nth-child(4) {
animation-delay: 2s;
animation-delay: 1.5s;
}

View File

@@ -0,0 +1,356 @@
import { createRequire } from 'node:module'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import CodeBlock from './code-block'
type UseThemeReturn = {
theme: Theme
}
const mockUseTheme = vi.fn<() => UseThemeReturn>(() => ({ theme: Theme.light }))
const require = createRequire(import.meta.url)
const echartsCjs = require('echarts') as {
getInstanceByDom: (dom: HTMLDivElement | null) => {
resize: (opts?: { width?: string, height?: string }) => void
} | null
}
let clientWidthSpy: { mockRestore: () => void } | null = null
let clientHeightSpy: { mockRestore: () => void } | null = null
let offsetWidthSpy: { mockRestore: () => void } | null = null
let offsetHeightSpy: { mockRestore: () => void } | null = null
type AudioContextCtor = new () => unknown
type WindowWithLegacyAudio = Window & {
AudioContext?: AudioContextCtor
webkitAudioContext?: AudioContextCtor
abcjsAudioContext?: unknown
}
let originalAudioContext: AudioContextCtor | undefined
let originalWebkitAudioContext: AudioContextCtor | undefined
class MockAudioContext {
state = 'running'
currentTime = 0
destination = {}
resume = vi.fn(async () => undefined)
decodeAudioData = vi.fn(async (_data: ArrayBuffer, success?: (audioBuffer: unknown) => void) => {
const mockAudioBuffer = {}
success?.(mockAudioBuffer)
return mockAudioBuffer
})
createBufferSource = vi.fn(() => ({
buffer: null as unknown,
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
onended: undefined as undefined | (() => void),
}))
}
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => mockUseTheme(),
}))
const findEchartsHost = async () => {
await waitFor(() => {
expect(document.querySelector('.echarts-for-react')).toBeInTheDocument()
})
return document.querySelector('.echarts-for-react') as HTMLDivElement
}
const findEchartsInstance = async () => {
const host = await findEchartsHost()
await waitFor(() => {
expect(echartsCjs.getInstanceByDom(host)).toBeTruthy()
})
return echartsCjs.getInstanceByDom(host)!
}
describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
clientHeightSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(400)
offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(900)
offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
const windowWithLegacyAudio = window as WindowWithLegacyAudio
originalAudioContext = windowWithLegacyAudio.AudioContext
originalWebkitAudioContext = windowWithLegacyAudio.webkitAudioContext
windowWithLegacyAudio.AudioContext = MockAudioContext as unknown as AudioContextCtor
windowWithLegacyAudio.webkitAudioContext = MockAudioContext as unknown as AudioContextCtor
delete windowWithLegacyAudio.abcjsAudioContext
})
afterEach(() => {
vi.useRealTimers()
clientWidthSpy?.mockRestore()
clientHeightSpy?.mockRestore()
offsetWidthSpy?.mockRestore()
offsetHeightSpy?.mockRestore()
clientWidthSpy = null
clientHeightSpy = null
offsetWidthSpy = null
offsetHeightSpy = null
const windowWithLegacyAudio = window as WindowWithLegacyAudio
if (originalAudioContext)
windowWithLegacyAudio.AudioContext = originalAudioContext
else
delete windowWithLegacyAudio.AudioContext
if (originalWebkitAudioContext)
windowWithLegacyAudio.webkitAudioContext = originalWebkitAudioContext
else
delete windowWithLegacyAudio.webkitAudioContext
delete windowWithLegacyAudio.abcjsAudioContext
originalAudioContext = undefined
originalWebkitAudioContext = undefined
})
// Base rendering behaviors for inline and language labels.
describe('Rendering', () => {
it('should render inline code element when inline prop is true', () => {
const { container } = render(<CodeBlock inline className="language-javascript">const a=1;</CodeBlock>)
const code = container.querySelector('code')
expect(code).toBeTruthy()
expect(code?.textContent).toBe('const a=1;')
})
it('should render code element when className does not include language prefix', () => {
const { container } = render(<CodeBlock className="plain">abc</CodeBlock>)
expect(container.querySelector('code')?.textContent).toBe('abc')
})
it('should render code element when className is not provided', () => {
const { container } = render(<CodeBlock>plain text</CodeBlock>)
expect(container.querySelector('code')?.textContent).toBe('plain text')
})
it('should render syntax-highlighted output when language is standard', () => {
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
})
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
render(<CodeBlock className="language-ruby">puts "ok"</CodeBlock>)
expect(screen.getByText('Ruby')).toBeInTheDocument()
})
it('should render mermaid controls when language is mermaid', async () => {
render(<CodeBlock className="language-mermaid">graph TB; A--&gt;B;</CodeBlock>)
expect(await screen.findByText('app.mermaid.classic')).toBeInTheDocument()
expect(screen.getByText('Mermaid')).toBeInTheDocument()
})
it('should render abc section header when language is abc', () => {
render(<CodeBlock className="language-abc">X:1\nT:test</CodeBlock>)
expect(screen.getByText('ABC')).toBeInTheDocument()
})
it('should hide svg renderer when toggle is clicked for svg language', async () => {
const user = userEvent.setup()
render(<CodeBlock className="language-svg">{'<svg/>'}</CodeBlock>)
expect(await screen.findByText(/Error rendering SVG/i)).toBeInTheDocument()
const svgToggleButton = screen.getAllByRole('button')[0]
await user.click(svgToggleButton)
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
})
it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
})
})
// ECharts behaviors for loading, parsing, and chart lifecycle updates.
describe('ECharts', () => {
it('should show loading indicator when echarts content is empty', () => {
render(<CodeBlock className="language-echarts"></CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep loading when echarts content is whitespace only', () => {
render(<CodeBlock className="language-echarts">{' '}</CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should render echarts with parsed option when JSON is valid', async () => {
const option = { title: [{ text: 'Hello' }] }
render(<CodeBlock className="language-echarts">{JSON.stringify(option)}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
})
it('should use error option when echarts content is invalid but structurally complete', async () => {
render(<CodeBlock className="language-echarts">{'{a:1}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
})
it('should use error option when echarts content is invalid non-structured text', async () => {
render(<CodeBlock className="language-echarts">{'not a json {'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
})
it('should keep loading when option is valid JSON but not an object', async () => {
render(<CodeBlock className="language-echarts">"text-value"</CodeBlock>)
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep loading when echarts content matches incomplete quote-pattern guard', async () => {
render(<CodeBlock className="language-echarts">{'x{"a":1'}</CodeBlock>)
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep loading when echarts content has unmatched opening array bracket', async () => {
render(<CodeBlock className="language-echarts">[[1,2]</CodeBlock>)
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep chart instance stable when window resize is triggered', async () => {
render(<CodeBlock className="language-echarts">{'{}'}</CodeBlock>)
await findEchartsHost()
act(() => {
window.dispatchEvent(new Event('resize'))
})
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should keep rendering when echarts content updates repeatedly', async () => {
const { rerender } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":3}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":4}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":5}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should stop processing extra finished events when chart finished callback fires repeatedly', async () => {
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
const chart = await findEchartsInstance()
const chartWithTrigger = chart as unknown as { trigger?: (eventName: string, event?: unknown) => void }
act(() => {
for (let i = 0; i < 8; i++) {
chartWithTrigger.trigger?.('finished', {})
chart.resize()
}
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 500))
})
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should switch from loading to chart when streaming content becomes valid JSON', async () => {
const { rerender } = render(<CodeBlock className="language-echarts">{'{ "a":'}</CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
rerender(<CodeBlock className="language-echarts">{'{ "a": 1 }'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should parse array JSON after previously incomplete streaming content', async () => {
const parseSpy = vi.spyOn(JSON, 'parse')
parseSpy.mockImplementationOnce(() => ({ series: [] }) as unknown as object)
const { rerender } = render(<CodeBlock className="language-echarts">[1, 2</CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
rerender(<CodeBlock className="language-echarts">[1, 2]</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
parseSpy.mockRestore()
})
it('should parse non-structured streaming content when JSON.parse fallback succeeds', async () => {
const parseSpy = vi.spyOn(JSON, 'parse')
parseSpy.mockImplementationOnce(() => ({ recovered: true }) as unknown as object)
render(<CodeBlock className="language-echarts">abcde</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
parseSpy.mockRestore()
})
it('should render dark themed echarts path when app theme is dark', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should render dark mode error option when app theme is dark and echarts content is invalid', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-echarts">{'{a:1}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should wire resize listener when echarts view re-enters with a ready chart instance', async () => {
const { rerender, unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
rerender(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
act(() => {
window.dispatchEvent(new Event('resize'))
})
expect(await findEchartsHost()).toBeInTheDocument()
unmount()
})
it('should cleanup echarts resize listener without pending timer on unmount', async () => {
const { unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
unmount()
})
})
})

View File

@@ -0,0 +1,164 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Link from './link'
// ---- mocks ----
const mockOnSend = vi.fn()
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({
onSend: mockOnSend,
}),
}))
const mockIsValidUrl = vi.fn()
vi.mock('./utils', () => ({
isValidUrl: (url: string) => mockIsValidUrl(url),
}))
describe('Link component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------
// ABBR LINK
// --------------------------
it('renders abbr link and calls onSend when clicked', () => {
const node = {
properties: {
href: 'abbr:hello%20world',
},
children: [{ value: 'Tooltip text' }],
}
render(<Link node={node} />)
const abbr = screen.getByText('Tooltip text')
expect(abbr.tagName).toBe('ABBR')
fireEvent.click(abbr)
expect(mockOnSend).toHaveBeenCalledWith('hello world')
})
// --------------------------
// HASH SCROLL LINK
// --------------------------
it('scrolls to target element when hash link clicked', () => {
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
const node = {
properties: {
href: '#section1',
},
}
const container = document.createElement('div')
container.className = 'chat-answer-container'
const target = document.createElement('div')
target.id = 'section1'
container.appendChild(target)
document.body.appendChild(container)
render(
<div className="chat-answer-container">
<div id="section1" />
<Link node={node}>Go</Link>
</div>,
)
const link = screen.getByText('Go')
fireEvent.click(link)
expect(scrollIntoView).toHaveBeenCalled()
})
// --------------------------
// INVALID URL
// --------------------------
it('renders span when url is invalid', () => {
mockIsValidUrl.mockReturnValue(false)
const node = {
properties: {
href: 'not-a-url',
},
}
render(<Link node={node}>Invalid</Link>)
const span = screen.getByText('Invalid')
expect(span.tagName).toBe('SPAN')
})
// --------------------------
// VALID EXTERNAL URL
// --------------------------
it('renders external link with target blank when url is valid', () => {
mockIsValidUrl.mockReturnValue(true)
const node = {
properties: {
href: 'https://example.com',
},
}
render(<Link node={node}>Visit</Link>)
const link = screen.getByText('Visit')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('href', 'https://example.com')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
// --------------------------
// NO HREF
// --------------------------
it('renders span when no href provided', () => {
const node = {
properties: {},
}
render(<Link node={node}>NoHref</Link>)
const span = screen.getByText('NoHref')
expect(span.tagName).toBe('SPAN')
})
// --------------------------
// DEFAULT TEXT FALLBACK
// --------------------------
it('renders default text for external link if children not provided', () => {
mockIsValidUrl.mockReturnValue(true)
const node = {
properties: {
href: 'https://example.com',
},
}
render(<Link node={node} />)
expect(screen.getByText('Download')).toBeInTheDocument()
})
it('renders default text for hash link if children not provided', () => {
const node = {
properties: {
href: '#section1',
},
}
render(<Link node={node} />)
expect(screen.getByText('ScrollView')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
import MarkdownMusic from './music'
describe('MarkdownMusic', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Base rendering behavior for the component shell.
describe('Rendering', () => {
it('should render wrapper and two internal container nodes', () => {
const { container } = render(<MarkdownMusic><span>child</span></MarkdownMusic>)
const topLevel = container.firstElementChild as HTMLElement | null
expect(topLevel).toBeTruthy()
expect(topLevel?.children.length).toBe(2)
expect(topLevel?.style.minWidth).toBe('100%')
expect(topLevel?.style.overflow).toBe('auto')
})
})
// String input triggers abcjs execution in jsdom; verify error is safely catchable.
describe('String Input', () => {
it('should render fallback when abcjs audio initialization fails in test environment', async () => {
render(
<ErrorBoundary>
<MarkdownMusic>{'X:1\nT:Test\nK:C\nC D E F|'}</MarkdownMusic>
</ErrorBoundary>,
)
expect(await screen.findByText(/Oops! An error occurred./i)).toBeInTheDocument()
})
it('should not render fallback when children is not a string', () => {
render(
<ErrorBoundary>
<MarkdownMusic><span>not a string</span></MarkdownMusic>
</ErrorBoundary>,
)
expect(screen.queryByText(/Oops! An error occurred./i)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,96 @@
import { cleanup, render, screen } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginImg } from './plugin-img'
/* -------------------- Mocks -------------------- */
vi.mock('@/app/components/base/image-gallery', () => ({
__esModule: true,
default: ({ srcs }: { srcs: string[] }) => (
<div data-testid="image-gallery">{srcs[0]}</div>
),
}))
const mockUsePluginReadmeAsset = vi.fn()
vi.mock('@/service/use-plugins', () => ({
usePluginReadmeAsset: (args: unknown) => mockUsePluginReadmeAsset(args),
}))
const mockGetMarkdownImageURL = vi.fn()
vi.mock('./utils', () => ({
getMarkdownImageURL: (src: string, pluginId?: string) =>
mockGetMarkdownImageURL(src, pluginId),
}))
/* -------------------- Tests -------------------- */
describe('PluginImg', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
it('uses blob URL when assetData exists', () => {
const fakeBlob = new Blob(['test'])
const fakeObjectUrl = 'blob:test-url'
mockUsePluginReadmeAsset.mockReturnValue({ data: fakeBlob })
mockGetMarkdownImageURL.mockReturnValue('fallback-url')
const createSpy = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue(fakeObjectUrl)
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL')
const { unmount } = render(
<PluginImg
src="file.png"
pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }}
/>,
)
const gallery = screen.getByTestId('image-gallery')
expect(gallery.textContent).toBe(fakeObjectUrl)
expect(createSpy).toHaveBeenCalledWith(fakeBlob)
unmount()
expect(revokeSpy).toHaveBeenCalledWith(fakeObjectUrl)
})
it('falls back to getMarkdownImageURL when no assetData', () => {
mockUsePluginReadmeAsset.mockReturnValue({ data: undefined })
mockGetMarkdownImageURL.mockReturnValue('computed-url')
render(
<PluginImg
src="file.png"
pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }}
/>,
)
const gallery = screen.getByTestId('image-gallery')
expect(gallery.textContent).toBe('computed-url')
expect(mockGetMarkdownImageURL).toHaveBeenCalledWith('file.png', '123')
})
it('works without pluginInfo', () => {
mockUsePluginReadmeAsset.mockReturnValue({ data: undefined })
mockGetMarkdownImageURL.mockReturnValue('default-url')
render(<PluginImg src="file.png" />)
const gallery = screen.getByTestId('image-gallery')
expect(gallery.textContent).toBe('default-url')
expect(mockGetMarkdownImageURL).toHaveBeenCalledWith('file.png', undefined)
})
})

View File

@@ -0,0 +1,69 @@
import { cleanup, render } from '@testing-library/react'
import * as React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import ScriptBlock from './script-block'
afterEach(() => {
cleanup()
})
type ScriptNode = {
children: Array<{ value?: string }>
}
describe('ScriptBlock', () => {
it('renders script tag string when child has value', () => {
const node: ScriptNode = {
children: [{ value: 'alert("hi")' }],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script>alert("hi")</script>')
})
it('renders empty script tag when child value is undefined', () => {
const node: ScriptNode = {
children: [{}],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script></script>')
})
it('renders empty script tag when children array is empty', () => {
const node: ScriptNode = {
children: [],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script></script>')
})
it('preserves multiline script content', () => {
const multi = `console.log("line1");
console.log("line2");`
const node: ScriptNode = {
children: [{ value: multi }],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe(`<script>${multi}</script>`)
})
it('has displayName set correctly', () => {
expect(ScriptBlock.displayName).toBe('ScriptBlock')
})
})

View File

@@ -0,0 +1,84 @@
import { render } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import VideoGallery from '../video-gallery'
import VideoBlock from './video-block'
type ChildNode = {
properties?: {
src?: string
}
}
type BlockNode = {
children: ChildNode[]
properties?: {
src?: string
}
}
describe('VideoBlock', () => {
it('renders multiple video sources from node.children', () => {
const node: BlockNode = {
children: [
{ properties: { src: 'a.mp4' } },
{ properties: { src: 'b.mp4' } },
],
}
render(<VideoBlock node={node} />)
const video = document.querySelector('video')
expect(video).toBeTruthy()
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(2)
expect(sources[0]).toHaveAttribute('src', 'a.mp4')
expect(sources[1]).toHaveAttribute('src', 'b.mp4')
})
it('renders single video from node.properties.src when no children srcs', () => {
const node: BlockNode = {
children: [],
properties: { src: 'single.mp4' },
}
render(<VideoBlock node={node} />)
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(1)
expect(sources[0]).toHaveAttribute('src', 'single.mp4')
})
it('returns null when no sources exist', () => {
const node: BlockNode = {
children: [],
properties: {},
}
const { container } = render(<VideoBlock node={node} />)
expect(container.innerHTML).toBe('')
})
it('has displayName set', () => {
expect(VideoBlock.displayName).toBe('VideoBlock')
})
})
describe('VideoGallery', () => {
it('returns null when srcs are empty or invalid', () => {
const { container } = render(<VideoGallery srcs={['', '']} />)
expect(container.innerHTML).toBe('')
})
it('renders video when valid srcs provided', () => {
render(<VideoGallery srcs={['ok.mp4', 'also.mp4']} />)
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(2)
expect(sources[0]).toHaveAttribute('src', 'ok.mp4')
expect(sources[1]).toHaveAttribute('src', 'also.mp4')
})
})

View File

@@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ErrorBoundary from './error-boundary'
import '@testing-library/jest-dom'
describe('ErrorBoundary', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
it('renders children when there is no error', () => {
render(
<ErrorBoundary>
<div data-testid="child">Hello world</div>
</ErrorBoundary>,
)
expect(screen.getByTestId('child')).toHaveTextContent('Hello world')
expect(consoleErrorSpy).not.toHaveBeenCalled()
})
it('catches errors thrown in children, shows fallback UI and logs the error', () => {
const testError = new Error('Test render error')
const Thrower: React.FC = () => {
throw testError
}
render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
)
expect(
screen.getByText(/Oops! An error occurred/i),
).toBeInTheDocument()
expect(consoleErrorSpy).toHaveBeenCalled()
const hasLoggedOurError = consoleErrorSpy.mock.calls.some((call: unknown[]) =>
call.includes(testError),
)
expect(hasLoggedOurError).toBe(true)
})
})

View File

@@ -0,0 +1,123 @@
import type { SimplePluginInfo } from './react-markdown-wrapper'
import { render, screen } from '@testing-library/react'
import { Markdown } from './index'
const { mockReactMarkdownWrapper } = vi.hoisted(() => ({
mockReactMarkdownWrapper: vi.fn(),
}))
vi.mock('next/dynamic', () => ({
default: () => (props: { latexContent: string }) => {
mockReactMarkdownWrapper(props)
return <div data-testid="react-markdown-wrapper">{props.latexContent}</div>
},
}))
type CapturedProps = {
latexContent: string
pluginInfo?: SimplePluginInfo
customComponents?: Record<string, unknown>
customDisallowedElements?: string[]
rehypePlugins?: unknown[]
}
const getLastWrapperProps = (): CapturedProps => {
const calls = mockReactMarkdownWrapper.mock.calls
const lastCall = calls[calls.length - 1]
return lastCall[0] as CapturedProps
}
describe('Markdown', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render wrapper content', () => {
render(<Markdown content="Hello World" />)
expect(screen.getByTestId('react-markdown-wrapper')).toHaveTextContent('Hello World')
})
it('should apply default classes', () => {
const { container } = render(<Markdown content="Test" />)
const markdownDiv = container.querySelector('.markdown-body')
expect(markdownDiv).toHaveClass('markdown-body', '!text-text-primary')
})
it('should merge custom className with default classes', () => {
const { container } = render(<Markdown content="Test" className="custom another" />)
const markdownDiv = container.querySelector('.markdown-body')
expect(markdownDiv).toHaveClass('markdown-body', '!text-text-primary', 'custom', 'another')
})
it('should not include undefined in className', () => {
const { container } = render(<Markdown content="Test" className={undefined} />)
const markdownDiv = container.querySelector('.markdown-body')
expect(markdownDiv?.className).not.toContain('undefined')
})
it('should preprocess think tags', () => {
render(<Markdown content="<think>Thought</think>" />)
const props = getLastWrapperProps()
expect(props.latexContent).toContain('<details data-think=true>')
expect(props.latexContent).toContain('Thought')
expect(props.latexContent).toContain('[ENDTHINKFLAG]</details>')
})
it('should preprocess latex block notation', () => {
render(<Markdown content={'\\[x^2 + y^2 = z^2\\]'} />)
const props = getLastWrapperProps()
expect(props.latexContent).toContain('$$x^2 + y^2 = z^2$$')
})
it('should preprocess latex parentheses notation', () => {
render(<Markdown content={'Inline \\(a + b\\) equation'} />)
const props = getLastWrapperProps()
expect(props.latexContent).toContain('$$a + b$$')
})
it('should preserve latex inside code blocks', () => {
render(<Markdown content={'```\n$E = mc^2$\n```'} />)
const props = getLastWrapperProps()
expect(props.latexContent).toContain('$E = mc^2$')
})
it('should pass pluginInfo through', () => {
const pluginInfo = {
pluginUniqueIdentifier: 'plugin-unique',
pluginId: 'plugin-id',
}
render(<Markdown content="content" pluginInfo={pluginInfo} />)
const props = getLastWrapperProps()
expect(props.pluginInfo).toEqual(pluginInfo)
})
it('should pass default empty customComponents when omitted', () => {
render(<Markdown content="content" />)
const props = getLastWrapperProps()
expect(props.customComponents).toEqual({})
})
it('should pass customComponents through', () => {
const customComponents = {
h1: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
}
render(<Markdown content="# title" customComponents={customComponents} />)
const props = getLastWrapperProps()
expect(props.customComponents).toBe(customComponents)
})
it('should pass customDisallowedElements through', () => {
const customDisallowedElements = ['strong', 'em']
render(<Markdown content="**bold**" customDisallowedElements={customDisallowedElements} />)
const props = getLastWrapperProps()
expect(props.customDisallowedElements).toBe(customDisallowedElements)
})
it('should pass rehypePlugins through', () => {
const plugin = () => (tree: unknown) => tree
const rehypePlugins = [plugin]
render(<Markdown content="content" rehypePlugins={rehypePlugins} />)
const props = getLastWrapperProps()
expect(props.rehypePlugins).toBe(rehypePlugins)
})
})

View File

@@ -0,0 +1,157 @@
// app/components/base/markdown/preprocess.spec.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Helper to (re)load the module with a mocked config value.
* We need to reset modules because the tested module imports
* ALLOW_UNSAFE_DATA_SCHEME at top-level.
*/
const loadModuleWithConfig = async (allowDataScheme: boolean) => {
vi.resetModules()
vi.doMock('@/config', () => ({ ALLOW_UNSAFE_DATA_SCHEME: allowDataScheme }))
return await import('./markdown-utils')
}
describe('preprocessLaTeX', () => {
let mod: typeof import('./markdown-utils')
beforeEach(async () => {
// config value doesn't matter for LaTeX preprocessing, mock it false
mod = await loadModuleWithConfig(false)
})
it('returns non-string input unchanged', () => {
// call with a non-string (bypass TS type system)
// @ts-expect-error test
const out = mod.preprocessLaTeX(123)
expect(out).toBe(123)
})
it('converts \\[ ... \\] into $$ ... $$', () => {
const input = 'This is math: \\[x^2 + 1\\]'
const out = mod.preprocessLaTeX(input)
expect(out).toContain('$$x^2 + 1$$')
})
it('converts \\( ... \\) into $$ ... $$', () => {
const input = 'Inline: \\(a+b\\)'
const out = mod.preprocessLaTeX(input)
expect(out).toContain('$$a+b$$')
})
it('preserves code blocks (does not transform $ inside them)', () => {
const input = [
'Some text before',
'```js',
'const s = \'$insideCode$\'',
'```',
'And outside $math$',
].join('\n')
const out = mod.preprocessLaTeX(input)
// code block should be preserved exactly (including $ inside)
expect(out).toContain('```js\nconst s = \'$insideCode$\'\n```')
// outside inline $math$ should remain intact (function keeps inline $...$)
expect(out).toContain('$math$')
})
it('does not treat escaped dollar \\$ as math delimiter', () => {
const input = 'Price: \\$5 and math $x$'
const out = mod.preprocessLaTeX(input)
// escaped dollar should remain escaped
expect(out).toContain('\\$5')
// math should still be present
expect(out).toContain('$x$')
})
})
describe('preprocessThinkTag', () => {
let mod: typeof import('./markdown-utils')
beforeEach(async () => {
mod = await loadModuleWithConfig(false)
})
it('transforms single <think>...</think> into details with data-think and ENDTHINKFLAG', () => {
const input = '<think>this is a thought</think>'
const out = mod.preprocessThinkTag(input)
expect(out).toContain('<details data-think=true>')
expect(out).toContain('this is a thought')
expect(out).toContain('[ENDTHINKFLAG]</details>')
})
it('handles multiple <think> tags and inserts newline after closing </details>', () => {
const input = '<think>one</think>\n<think>two</think>'
const out = mod.preprocessThinkTag(input)
// both thoughts become details blocks
const occurrences = (out.match(/<details data-think=true>/g) || []).length
expect(occurrences).toBe(2)
// ensure ENDTHINKFLAG is present twice
const endCount = (out.match(/\[ENDTHINKFLAG\]<\/details>/g) || []).length
expect(endCount).toBe(2)
})
})
describe('customUrlTransform', () => {
afterEach(() => {
vi.resetAllMocks()
vi.resetModules()
})
it('allows fragments (#foo) and protocol-relative (//host) and relative paths', async () => {
const mod = await loadModuleWithConfig(false)
const t = mod.customUrlTransform
expect(t('#some-id')).toBe('#some-id')
expect(t('//example.com/path')).toBe('//example.com/path')
expect(t('relative/path/to/file')).toBe('relative/path/to/file')
expect(t('/absolute/path')).toBe('/absolute/path')
})
it('allows permitted schemes (http, https, mailto, xmpp, irc/ircs, abbr) case-insensitively', async () => {
const mod = await loadModuleWithConfig(false)
const t = mod.customUrlTransform
expect(t('http://example.com')).toBe('http://example.com')
expect(t('HTTPS://example.com')).toBe('HTTPS://example.com')
expect(t('mailto:user@example.com')).toBe('mailto:user@example.com')
expect(t('xmpp:user@example.com')).toBe('xmpp:user@example.com')
expect(t('irc:somewhere')).toBe('irc:somewhere')
expect(t('ircs:secure')).toBe('ircs:secure')
expect(t('abbr:some-ref')).toBe('abbr:some-ref')
})
it('rejects unknown/unsafe schemes (javascript:, ftp:) and returns undefined', async () => {
const mod = await loadModuleWithConfig(false)
const t = mod.customUrlTransform
expect(t('javascript:alert(1)')).toBeUndefined()
expect(t('ftp://example.com/file')).toBeUndefined()
})
it('treats colons inside path/query/fragment as NOT a scheme and returns the original URI', async () => {
const mod = await loadModuleWithConfig(false)
const t = mod.customUrlTransform
// colon after a slash -> part of path
expect(t('folder/name:withcolon')).toBe('folder/name:withcolon')
// colon after question mark -> part of query
expect(t('page?param:http')).toBe('page?param:http')
// colon after hash -> part of fragment
expect(t('page#frag:with:colon')).toBe('page#frag:with:colon')
})
it('respects ALLOW_UNSAFE_DATA_SCHEME: false blocks data:, true allows data:', async () => {
const modFalse = await loadModuleWithConfig(false)
expect(modFalse.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBeUndefined()
const modTrue = await loadModuleWithConfig(true)
expect(modTrue.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBe('data:text/plain;base64,SGVsbG8=')
})
})

View File

@@ -0,0 +1,271 @@
import type { DataSourceCredential } from '../../header/account-setting/data-source-page-new/types'
import type { DataSourceNotionWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import { useModalContextSelector } from '@/context/modal-context'
import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import'
import NotionPageSelector from './base'
vi.mock('@/service/knowledge/use-import', () => ({
usePreImportNotionPages: vi.fn(),
useInvalidPreImportNotionPages: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: vi.fn(),
}))
const buildCredential = (
id: string,
name: string,
workspaceName: string,
): DataSourceCredential => ({
id,
name,
type: CredentialTypeEnum.OAUTH2,
is_default: false,
avatar_url: '',
credential: {
workspace_icon: '',
workspace_name: workspaceName,
},
})
const mockCredentialList: DataSourceCredential[] = [
buildCredential('c1', 'Cred 1', 'Workspace 1'),
buildCredential('c2', 'Cred 2', 'Workspace 2'),
]
const mockNotionWorkspaces: DataSourceNotionWorkspace[] = [
{
workspace_id: 'w1',
workspace_icon: '',
workspace_name: 'Workspace 1',
pages: [
{ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false },
{ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false },
{ page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true },
],
},
{
workspace_id: 'w2',
workspace_icon: '',
workspace_name: 'Workspace 2',
pages: [
{ page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false },
],
},
]
const createPreImportResult = ({
notionInfo = mockNotionWorkspaces,
isFetching = false,
isError = false,
}: {
notionInfo?: DataSourceNotionWorkspace[]
isFetching?: boolean
isError?: boolean
} = {}) =>
({
data: { notion_info: notionInfo },
isFetching,
isError,
}) as ReturnType<typeof usePreImportNotionPages>
describe('NotionPageSelector Base', () => {
const mockSetShowAccountSettingModal = vi.fn()
const mockInvalidPreImportNotionPages = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContextSelector).mockReturnValue(mockSetShowAccountSettingModal)
vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages)
})
it('should render loading state when pages are being fetched', () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isFetching: true }))
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
expect(screen.getByTestId('notion-page-selector-loading')).toBeInTheDocument()
})
it('should render connector and open settings when fetch fails', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isError: true }))
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
const connectButton = screen.getByRole('button', { name: 'datasetCreation.stepOne.connect' })
await user.click(connectButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
})
it('should render page selector and allow selecting a page tree', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const handleSelect = vi.fn()
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={handleSelect} />)
expect(screen.getByTestId('notion-page-selector-base')).toBeInTheDocument()
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
await user.click(checkbox)
expect(handleSelect).toHaveBeenCalled()
expect(handleSelect).toHaveBeenLastCalledWith(expect.arrayContaining([
expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }),
expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }),
expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }),
]))
})
it('should keep bound pages disabled and selected by default', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const handleSelect = vi.fn()
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={handleSelect} />)
const boundCheckbox = screen.getByTestId('checkbox-notion-page-checkbox-bound-1')
expect(screen.getByTestId('check-icon-notion-page-checkbox-bound-1')).toBeInTheDocument()
await user.click(boundCheckbox)
expect(handleSelect).not.toHaveBeenCalled()
})
it('should filter and clear search results from search input actions', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
const searchInput = screen.getByTestId('notion-search-input')
await user.type(searchInput, 'no-such-page')
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
await user.click(screen.getByTestId('notion-search-input-clear'))
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
})
it('should switch credential and reset selection when choosing a different workspace', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const handleSelect = vi.fn()
const onSelectCredential = vi.fn()
const user = userEvent.setup()
render(
<NotionPageSelector
credentialList={mockCredentialList}
onSelect={handleSelect}
onSelectCredential={onSelectCredential}
datasetId="dataset-1"
/>,
)
const selectorBtn = screen.getByTestId('notion-credential-selector-btn')
await user.click(selectorBtn)
const item2 = screen.getByTestId('notion-credential-item-c2')
await user.click(item2)
expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' })
expect(handleSelect).toHaveBeenCalledWith([])
expect(onSelectCredential).toHaveBeenLastCalledWith('c2')
})
it('should open settings when configuration action in header is clicked', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Configure Notion' }))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
})
it('should preview a page and call onPreview when callback is provided', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const onPreview = vi.fn()
const user = userEvent.setup()
render(
<NotionPageSelector
credentialList={mockCredentialList}
onSelect={vi.fn()}
onPreview={onPreview}
previewPageId="root-1"
/>,
)
const previewBtn = screen.getByTestId('notion-page-preview-root-1')
await user.click(previewBtn)
expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }))
})
it('should handle preview click without onPreview callback', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
await user.click(screen.getByTestId('notion-page-preview-root-1'))
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
})
it('should call onSelectCredential with current credential on initial render', () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const onSelectCredential = vi.fn()
render(
<NotionPageSelector
credentialList={mockCredentialList}
onSelect={vi.fn()}
onSelectCredential={onSelectCredential}
/>,
)
expect(onSelectCredential).toHaveBeenCalledWith('c1')
})
it('should fallback to first credential when current credential is removed in error mode', async () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isError: true }))
const onSelect = vi.fn()
const onSelectCredential = vi.fn()
const { rerender } = render(
<NotionPageSelector
credentialList={mockCredentialList}
onSelect={onSelect}
onSelectCredential={onSelectCredential}
datasetId="dataset-fallback"
/>,
)
rerender(
<NotionPageSelector
credentialList={[buildCredential('c3', 'Cred 3', 'Workspace 3')]}
onSelect={onSelect}
onSelectCredential={onSelectCredential}
datasetId="dataset-fallback"
/>,
)
await waitFor(() => {
expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-fallback', credentialId: 'c3' })
expect(onSelect).toHaveBeenCalledWith([])
expect(onSelectCredential).toHaveBeenLastCalledWith('c3')
})
})
it('should update selected page state when controlled value changes', () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
const { rerender } = render(
<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={['root-1']} />,
)
expect(screen.getByTestId('check-icon-notion-page-checkbox-root-1')).toBeInTheDocument()
rerender(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={[]} />)
expect(screen.queryByTestId('check-icon-notion-page-checkbox-root-1')).not.toBeInTheDocument()
})
it('should hide preview actions when canPreview is false', () => {
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} canPreview={false} />)
expect(screen.queryByTestId('notion-page-preview-root-1')).not.toBeInTheDocument()
})
})

View File

@@ -137,7 +137,7 @@ const NotionPageSelector = ({
}
return (
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-y-2" data-testid="notion-page-selector-base">
<Header
onClickConfiguration={handleConfigureNotion}
title="Choose notion pages"
@@ -162,7 +162,7 @@ const NotionPageSelector = ({
<div className="overflow-hidden rounded-b-xl">
{isFetchingNotionPages
? (
<div className="flex h-[296px] items-center justify-center">
<div className="flex h-[296px] items-center justify-center" data-testid="notion-page-selector-loading">
<Loading />
</div>
)

View File

@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import CredentialSelector from './index'
// Mock CredentialIcon since it's likely a complex component or uses next/image
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
}))
const mockItems = [
{
credentialId: '1',
credentialName: 'Workspace 1',
workspaceName: 'Notion Workspace 1',
},
{
credentialId: '2',
credentialName: 'Workspace 2',
workspaceName: 'Notion Workspace 2',
},
]
describe('CredentialSelector', () => {
it('should render current workspace name', () => {
render(<CredentialSelector value="1" items={mockItems} onSelect={vi.fn()} />)
expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent('Notion Workspace 1')
})
it('should show all workspaces when menu is clicked', async () => {
const user = userEvent.setup()
render(<CredentialSelector value="1" items={mockItems} onSelect={vi.fn()} />)
const btn = screen.getByTestId('notion-credential-selector-btn')
await user.click(btn)
expect(screen.getByTestId('notion-credential-item-1')).toBeInTheDocument()
expect(screen.getByTestId('notion-credential-item-2')).toBeInTheDocument()
})
it('should call onSelect when a workspace is clicked', async () => {
const handleSelect = vi.fn()
const user = userEvent.setup()
render(<CredentialSelector value="1" items={mockItems} onSelect={handleSelect} />)
const btn = screen.getByTestId('notion-credential-selector-btn')
await user.click(btn)
const item2 = screen.getByTestId('notion-credential-item-2')
await user.click(item2)
expect(handleSelect).toHaveBeenCalledWith('2')
})
it('should use credentialName if workspaceName is missing', () => {
const itemsWithoutWorkspaceName = [
{
credentialId: '1',
credentialName: 'Credential Name 1',
},
]
render(<CredentialSelector value="1" items={itemsWithoutWorkspaceName} onSelect={vi.fn()} />)
expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent('Credential Name 1')
})
})

View File

@@ -1,6 +1,5 @@
'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { Fragment, useMemo } from 'react'
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
@@ -38,7 +37,10 @@ const CredentialSelector = ({
{
({ open }) => (
<>
<MenuButton className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}>
<MenuButton
className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}
data-testid="notion-credential-selector-btn"
>
<CredentialIcon
className="mr-2"
avatarUrl={currentCredential?.workspaceIcon}
@@ -48,10 +50,11 @@ const CredentialSelector = ({
<div
className="mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary"
title={currentDisplayName}
data-testid="notion-credential-selector-name"
>
{currentDisplayName}
</div>
<RiArrowDownSLine className="h-4 w-4 text-text-secondary" />
<div className="i-ri-arrow-down-s-line h-4 w-4 text-text-secondary" />
</MenuButton>
<Transition
as={Fragment}
@@ -76,6 +79,7 @@ const CredentialSelector = ({
<div
className="flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(item.credentialId)}
data-testid={`notion-credential-item-${item.credentialId}`}
>
<CredentialIcon
className="mr-2 shrink-0"
@@ -84,7 +88,7 @@ const CredentialSelector = ({
size={20}
/>
<div
className="system-sm-medium mr-2 grow truncate text-text-secondary"
className="mr-2 grow truncate text-text-secondary system-sm-medium"
title={displayName}
>
{displayName}

View File

@@ -0,0 +1,127 @@
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PageSelector from './index'
const buildPage = (overrides: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({
page_id: 'page-id',
page_name: 'Page name',
parent_id: 'root',
page_icon: null,
type: 'page',
is_bound: false,
...overrides,
})
const mockList: DataSourceNotionPage[] = [
buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }),
buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }),
buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }),
]
const mockPagesMap: DataSourceNotionPageMap = {
'root-1': { ...mockList[0], workspace_id: 'workspace-1' },
'child-1': { ...mockList[1], workspace_id: 'workspace-1' },
'grandchild-1': { ...mockList[2], workspace_id: 'workspace-1' },
}
describe('PageSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render root level pages initially', () => {
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
expect(screen.getByText('Root 1')).toBeInTheDocument()
expect(screen.queryByText('Child 1')).not.toBeInTheDocument()
})
it('should expand child pages when toggle is clicked', async () => {
const user = userEvent.setup()
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
const toggle = screen.getByTestId('notion-page-toggle-root-1')
await user.click(toggle)
expect(screen.getByText('Child 1')).toBeInTheDocument()
})
it('should call onSelect with descendants when parent is selected', async () => {
const handleSelect = vi.fn()
const user = userEvent.setup()
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
await user.click(checkbox)
expect(handleSelect).toHaveBeenCalledWith(new Set(['root-1', 'child-1', 'grandchild-1']))
})
it('should call onSelect with empty set when parent is deselected', async () => {
const handleSelect = vi.fn()
const user = userEvent.setup()
render(<PageSelector value={new Set(['root-1', 'child-1', 'grandchild-1'])} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
await user.click(checkbox)
expect(handleSelect).toHaveBeenCalledWith(new Set())
})
it('should show breadcrumbs when searching', () => {
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Grandchild" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
expect(screen.getByText('Root 1 / Child 1 / Grandchild 1')).toBeInTheDocument()
})
it('should call onPreview when preview button is clicked', async () => {
const handlePreview = vi.fn()
const user = userEvent.setup()
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} onPreview={handlePreview} />)
const previewBtn = screen.getByTestId('notion-page-preview-root-1')
await user.click(previewBtn)
expect(handlePreview).toHaveBeenCalledWith('root-1')
})
it('should show no result message when search returns nothing', () => {
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="nonexistent" pagesMap={mockPagesMap} list={[]} onSelect={vi.fn()} />)
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
})
it('should handle selection when searchValue is present', async () => {
const handleSelect = vi.fn()
const user = userEvent.setup()
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-child-1')
await user.click(checkbox)
expect(handleSelect).toHaveBeenCalledWith(new Set(['child-1']))
})
it('should handle preview when onPreview is not provided', async () => {
const user = userEvent.setup()
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
const previewBtn = screen.getByTestId('notion-page-preview-root-1')
await user.click(previewBtn)
// Should not crash
})
it('should handle toggle when item is already expanded', async () => {
const user = userEvent.setup()
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
const toggleBtn = screen.getByTestId('notion-page-toggle-root-1')
await user.click(toggleBtn) // Expand
await waitFor(() => expect(screen.queryByText('Child 1')).toBeInTheDocument())
await user.click(toggleBtn) // Collapse
await waitFor(() => expect(screen.queryByText('Child 1')).not.toBeInTheDocument())
})
})

View File

@@ -1,6 +1,5 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { areEqual, FixedSizeList as List } from 'react-window'
@@ -110,11 +109,12 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }}
onClick={() => handleToggle(index)}
data-testid={`notion-page-toggle-${current.page_id}`}
>
{
current.expand
? <RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
: <RiArrowRightSLine className="h-4 w-4 text-text-tertiary" />
? <div className="i-ri-arrow-down-s-line h-4 w-4 text-text-tertiary" />
: <div className="i-ri-arrow-right-s-line h-4 w-4 text-text-tertiary" />
}
</div>
)
@@ -141,6 +141,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
onCheck={() => {
handleCheck(index)
}}
id={`notion-page-checkbox-${current.page_id}`}
/>
{!searchValue && renderArrow()}
<NotionIcon
@@ -151,6 +152,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
<div
className="grow truncate text-[13px] font-medium leading-4 text-text-secondary"
title={current.page_name}
data-testid={`notion-page-name-${current.page_id}`}
>
{current.page_name}
</div>
@@ -161,6 +163,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => handlePreview(index)}
data-testid={`notion-page-preview-${current.page_id}`}
>
{t('dataSource.notion.selector.preview', { ns: 'common' })}
</div>

View File

@@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import SearchInput from './index'
describe('SearchInput', () => {
it('should render with placeholder', () => {
render(<SearchInput value="" onChange={vi.fn()} />)
expect(screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages')).toBeInTheDocument()
expect(screen.getByTestId('notion-search-input-container')).toBeInTheDocument()
})
it('should call onChange when typing', async () => {
const handleChange = vi.fn()
const user = userEvent.setup()
render(<SearchInput value="" onChange={handleChange} />)
const input = screen.getByTestId('notion-search-input')
await user.type(input, 'test query')
expect(handleChange).toHaveBeenCalled()
})
it('should show clear button when value is not empty', () => {
render(<SearchInput value="some value" onChange={vi.fn()} />)
expect(screen.getByTestId('notion-search-input-clear')).toBeInTheDocument()
})
it('should call onChange with empty string when clear button is clicked', async () => {
const handleChange = vi.fn()
const user = userEvent.setup()
render(<SearchInput value="some value" onChange={handleChange} />)
const clearBtn = screen.getByTestId('notion-search-input-clear')
await user.click(clearBtn)
expect(handleChange).toHaveBeenCalledWith('')
})
it('should not show clear button when value is empty', () => {
render(<SearchInput value="" onChange={vi.fn()} />)
expect(screen.queryByTestId('notion-search-input-clear')).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,4 @@
import type { ChangeEvent } from 'react'
import { RiCloseCircleFill, RiSearchLine } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
@@ -19,19 +18,24 @@ const SearchInput = ({
}, [onChange])
return (
<div className={cn('flex h-8 w-[200px] items-center rounded-lg bg-components-input-bg-normal p-2')}>
<RiSearchLine className="mr-0.5 h-4 w-4 shrink-0 text-components-input-text-placeholder" />
<div
className={cn('flex h-8 w-[200px] items-center rounded-lg bg-components-input-bg-normal p-2')}
data-testid="notion-search-input-container"
>
<div className="i-ri-search-line mr-0.5 h-4 w-4 shrink-0 text-components-input-text-placeholder" />
<input
className="min-w-0 grow appearance-none border-0 bg-transparent px-1 text-[13px] leading-[16px] text-components-input-text-filled outline-0 placeholder:text-components-input-text-placeholder"
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
placeholder={t('dataSource.notion.selector.searchPages', { ns: 'common' }) || ''}
data-testid="notion-search-input"
/>
{
value && (
<RiCloseCircleFill
className="h-4 w-4 shrink-0 cursor-pointer text-components-input-text-placeholder"
<div
className="i-ri-close-circle-fill h-4 w-4 shrink-0 cursor-pointer text-components-input-text-placeholder"
onClick={handleClear}
data-testid="notion-search-input-clear"
/>
)
}

View File

@@ -0,0 +1,113 @@
import { SupportUploadFileTypes } from '../../workflow/types'
import {
checkHasContextBlock,
checkHasHistoryBlock,
checkHasQueryBlock,
checkHasRequestURLBlock,
CONTEXT_PLACEHOLDER_TEXT,
CURRENT_PLACEHOLDER_TEXT,
ERROR_MESSAGE_PLACEHOLDER_TEXT,
FILE_EXTS,
getInputVars,
HISTORY_PLACEHOLDER_TEXT,
LAST_RUN_PLACEHOLDER_TEXT,
PRE_PROMPT_PLACEHOLDER_TEXT,
QUERY_PLACEHOLDER_TEXT,
REQUEST_URL_PLACEHOLDER_TEXT,
UPDATE_DATASETS_EVENT_EMITTER,
UPDATE_HISTORY_EVENT_EMITTER,
} from './constants'
describe('prompt-editor constants', () => {
describe('placeholder and event constants', () => {
it('should expose expected placeholder constants', () => {
expect(CONTEXT_PLACEHOLDER_TEXT).toBe('{{#context#}}')
expect(HISTORY_PLACEHOLDER_TEXT).toBe('{{#histories#}}')
expect(QUERY_PLACEHOLDER_TEXT).toBe('{{#query#}}')
expect(REQUEST_URL_PLACEHOLDER_TEXT).toBe('{{#url#}}')
expect(CURRENT_PLACEHOLDER_TEXT).toBe('{{#current#}}')
expect(ERROR_MESSAGE_PLACEHOLDER_TEXT).toBe('{{#error_message#}}')
expect(LAST_RUN_PLACEHOLDER_TEXT).toBe('{{#last_run#}}')
expect(PRE_PROMPT_PLACEHOLDER_TEXT).toBe('{{#pre_prompt#}}')
})
it('should expose expected event emitter constants', () => {
expect(UPDATE_DATASETS_EVENT_EMITTER).toBe('prompt-editor-context-block-update-datasets')
expect(UPDATE_HISTORY_EVENT_EMITTER).toBe('prompt-editor-history-block-update-role')
})
})
describe('check block helpers', () => {
it('should detect context placeholder only when present', () => {
expect(checkHasContextBlock('')).toBe(false)
expect(checkHasContextBlock('plain text')).toBe(false)
expect(checkHasContextBlock(`before ${CONTEXT_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect history placeholder only when present', () => {
expect(checkHasHistoryBlock('')).toBe(false)
expect(checkHasHistoryBlock('plain text')).toBe(false)
expect(checkHasHistoryBlock(`before ${HISTORY_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect query placeholder only when present', () => {
expect(checkHasQueryBlock('')).toBe(false)
expect(checkHasQueryBlock('plain text')).toBe(false)
expect(checkHasQueryBlock(`before ${QUERY_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect request url placeholder only when present', () => {
expect(checkHasRequestURLBlock('')).toBe(false)
expect(checkHasRequestURLBlock('plain text')).toBe(false)
expect(checkHasRequestURLBlock(`before ${REQUEST_URL_PLACEHOLDER_TEXT} after`)).toBe(true)
})
})
describe('getInputVars', () => {
it('should return empty array for invalid or empty input', () => {
expect(getInputVars('')).toEqual([])
expect(getInputVars('plain text without vars')).toEqual([])
expect(getInputVars(null as unknown as string)).toEqual([])
})
it('should ignore placeholders that are not input vars', () => {
const text = `a ${CONTEXT_PLACEHOLDER_TEXT} b ${QUERY_PLACEHOLDER_TEXT} c`
expect(getInputVars(text)).toEqual([])
})
it('should parse regular input vars with dotted selectors', () => {
const text = 'value {{#node123.result.answer#}} and {{#abc.def#}}'
expect(getInputVars(text)).toEqual([
['node123', 'result', 'answer'],
['abc', 'def'],
])
})
it('should strip numeric node id for sys selector vars', () => {
const text = 'value {{#1711617514996.sys.query#}}'
expect(getInputVars(text)).toEqual([
['sys', 'query'],
])
})
it('should keep selector unchanged when sys prefix is not numeric id', () => {
const text = 'value {{#abc.sys.query#}}'
expect(getInputVars(text)).toEqual([
['abc', 'sys', 'query'],
])
})
})
describe('file extension map', () => {
it('should expose expected file extensions for each supported type', () => {
expect(FILE_EXTS[SupportUploadFileTypes.image]).toContain('PNG')
expect(FILE_EXTS[SupportUploadFileTypes.document]).toContain('PDF')
expect(FILE_EXTS[SupportUploadFileTypes.audio]).toContain('MP3')
expect(FILE_EXTS[SupportUploadFileTypes.video]).toContain('MP4')
})
})
})

View File

@@ -0,0 +1,110 @@
import type { RefObject } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { CurrentBlockNode, DELETE_CURRENT_BLOCK_COMMAND } from '.'
import { CustomTextNode } from '../custom-text/node'
import CurrentBlockComponent from './component'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
const renderComponent = (props?: {
isSelected?: boolean
withNode?: boolean
onParentClick?: () => void
generatorType?: GeneratorType
}) => {
const {
isSelected = false,
withNode = true,
onParentClick,
generatorType = GeneratorType.prompt,
} = props ?? {}
mockUseSelectOrDelete.mockReturnValue(createHookReturn(isSelected))
return render(
<LexicalComposer
initialConfig={{
namespace: 'current-block-component-test',
onError: (error: Error) => {
throw error
},
nodes: withNode ? [CustomTextNode, CurrentBlockNode] : [CustomTextNode],
}}
>
<div onClick={onParentClick}>
<CurrentBlockComponent nodeKey="current-node" generatorType={generatorType} />
</div>
</LexicalComposer>,
)
}
describe('CurrentBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render prompt label and selected classes when generator type is prompt and selected', () => {
const { container } = renderComponent({
generatorType: GeneratorType.prompt,
isSelected: true,
})
const wrapper = container.querySelector('.group\\/wrap')
expect(screen.getByText('current_prompt')).toBeInTheDocument()
expect(wrapper).toHaveClass('border-state-accent-solid')
expect(wrapper).toHaveClass('bg-state-accent-hover')
})
it('should render code label and default classes when generator type is code and not selected', () => {
const { container } = renderComponent({
generatorType: GeneratorType.code,
isSelected: false,
})
const wrapper = container.querySelector('.group\\/wrap')
expect(screen.getByText('current_code')).toBeInTheDocument()
expect(wrapper).toHaveClass('border-components-panel-border-subtle')
expect(wrapper).toHaveClass('bg-components-badge-white-to-dark')
})
it('should wire useSelectOrDelete with node key and delete command', () => {
renderComponent({ generatorType: GeneratorType.prompt })
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('current-node', DELETE_CURRENT_BLOCK_COMMAND)
})
})
describe('Interactions', () => {
it('should stop click propagation from wrapper', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
renderComponent({ onParentClick, generatorType: GeneratorType.prompt })
await user.click(screen.getByText('current_prompt'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
describe('Node registration guard', () => {
it('should throw when current block node is not registered on editor', () => {
expect(() => {
renderComponent({ withNode: false })
}).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,118 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { CURRENT_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import CurrentBlockReplacementBlock from './current-block-replacement-block'
import { CurrentBlockNode } from './index'
const renderReplacementPlugin = (props?: {
generatorType?: GeneratorType
onInsert?: () => void
}) => {
const {
generatorType = GeneratorType.prompt,
onInsert,
} = props ?? {}
return renderLexicalEditor({
namespace: 'current-block-replacement-plugin-test',
nodes: [CustomTextNode, CurrentBlockNode],
children: (
<CurrentBlockReplacementBlock generatorType={generatorType} onInsert={onInsert} />
),
})
}
const getCurrentNodeGeneratorTypes = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
return $nodesOfType(CurrentBlockNode).map(node => node.getGeneratorType())
})
}
describe('CurrentBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text and call onInsert when placeholder exists', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({
generatorType: GeneratorType.prompt,
onInsert,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${CURRENT_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
})
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.prompt])
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({
generatorType: GeneratorType.prompt,
onInsert,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without current placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, CurrentBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin({
generatorType: GeneratorType.code,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, CURRENT_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
})
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.code])
})
})
describe('Node registration guard', () => {
it('should throw when current block node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'current-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<CurrentBlockReplacementBlock generatorType={GeneratorType.prompt} />
</LexicalComposer>,
)
}).toThrow('CurrentBlockNodePlugin: CurrentBlockNode not registered on editor')
})
})
})

View File

@@ -0,0 +1,168 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { CURRENT_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
CurrentBlock,
CurrentBlockNode,
DELETE_CURRENT_BLOCK_COMMAND,
INSERT_CURRENT_BLOCK_COMMAND,
} from './index'
const renderCurrentBlock = (props?: {
generatorType?: GeneratorType
onInsert?: () => void
onDelete?: () => void
}) => {
const {
generatorType = GeneratorType.prompt,
onInsert,
onDelete,
} = props ?? {}
return renderLexicalEditor({
namespace: 'current-block-plugin-test',
nodes: [CustomTextNode, CurrentBlockNode],
children: (
<CurrentBlock generatorType={generatorType} onInsert={onInsert} onDelete={onDelete} />
),
})
}
const getCurrentNodeGeneratorTypes = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
return $nodesOfType(CurrentBlockNode).map(node => node.getGeneratorType())
})
}
describe('CurrentBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert current block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderCurrentBlock({
generatorType: GeneratorType.prompt,
onInsert,
})
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(CURRENT_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.prompt])
})
it('should insert current block without onInsert callback', async () => {
const { getEditor } = renderCurrentBlock({
generatorType: GeneratorType.code,
})
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(CURRENT_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.code])
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderCurrentBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderCurrentBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderCurrentBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_CURRENT_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when current block node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'current-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<CurrentBlock generatorType={GeneratorType.prompt} />
</LexicalComposer>,
)
}).toThrow('CURRENTBlockPlugin: CURRENTBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,195 @@
import { act } from '@testing-library/react'
import {
$createParagraphNode,
$getRoot,
} from 'lexical'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import CurrentBlockComponent from './component'
import {
$createCurrentBlockNode,
$isCurrentBlockNode,
CurrentBlockNode,
} from './node'
const createTestEditor = () => {
return createLexicalTestEditor('current-block-node-test', [CurrentBlockNode])
}
const appendNodeToRoot = (node: CurrentBlockNode) => {
const paragraph = $createParagraphNode()
paragraph.append(node)
$getRoot().append(paragraph)
}
describe('CurrentBlockNode', () => {
describe('Node metadata', () => {
it('should expose current block type, inline behavior, and text content', () => {
const editor = createTestEditor()
let isInline = false
let textContent = ''
let generatorType!: GeneratorType
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
isInline = node.isInline()
textContent = node.getTextContent()
generatorType = node.getGeneratorType()
})
})
expect(CurrentBlockNode.getType()).toBe('current-block')
expect(isInline).toBe(true)
expect(textContent).toBe('{{#current#}}')
expect(generatorType).toBe(GeneratorType.prompt)
})
it('should clone with the same key and generator type', () => {
const editor = createTestEditor()
let originalKey = ''
let clonedKey = ''
let clonedGeneratorType!: GeneratorType
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.code)
appendNodeToRoot(node)
const cloned = CurrentBlockNode.clone(node)
originalKey = node.getKey()
clonedKey = cloned.getKey()
clonedGeneratorType = cloned.getGeneratorType()
})
})
expect(clonedKey).toBe(originalKey)
expect(clonedGeneratorType).toBe(GeneratorType.code)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON with generator type', () => {
const editor = createTestEditor()
let serialized!: ReturnType<CurrentBlockNode['exportJSON']>
let importedSerialized!: ReturnType<CurrentBlockNode['exportJSON']>
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
serialized = node.exportJSON()
const imported = CurrentBlockNode.importJSON({
type: 'current-block',
version: 1,
generatorType: GeneratorType.code,
})
appendNodeToRoot(imported)
importedSerialized = imported.exportJSON()
})
})
expect(serialized).toEqual({
type: 'current-block',
version: 1,
generatorType: GeneratorType.prompt,
})
expect(importedSerialized).toEqual({
type: 'current-block',
version: 1,
generatorType: GeneratorType.code,
})
})
it('should decorate with current block component and props', () => {
const editor = createTestEditor()
let nodeKey = ''
let element!: ReturnType<CurrentBlockNode['decorate']>
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.code)
appendNodeToRoot(node)
nodeKey = node.getKey()
element = node.decorate()
})
})
expect(element.type).toBe(CurrentBlockComponent)
expect(element.props).toEqual({
nodeKey,
generatorType: GeneratorType.code,
})
})
})
describe('Helpers', () => {
it('should create current block node instance from factory', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect(node).toBeInstanceOf(CurrentBlockNode)
})
it('should identify current block nodes using type guard helper', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect($isCurrentBlockNode(node)).toBe(true)
expect($isCurrentBlockNode(null)).toBe(false)
expect($isCurrentBlockNode(undefined)).toBe(false)
})
})
})

View File

@@ -0,0 +1,95 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useSelectOrDelete } from '../../hooks'
import ErrorMessageBlockComponent from './component'
import { DELETE_ERROR_MESSAGE_COMMAND, ErrorMessageBlockNode } from './index'
vi.mock('../../hooks')
const mockHasNodes = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('ErrorMessageBlockComponent', () => {
const mockRef = { current: null as HTMLDivElement | null }
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, false])
})
describe('Rendering', () => {
it('should render error_message text and base styles when unselected', () => {
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
expect(screen.getByText('error_message')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('border-components-panel-border-subtle')
})
it('should render selected styles when node is selected', () => {
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, true])
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
expect(container.firstChild).toHaveClass('border-state-accent-solid')
expect(container.firstChild).toHaveClass('bg-state-accent-hover')
})
})
describe('Interactions', () => {
it('should stop propagation when wrapper is clicked', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
<div onClick={onParentClick}>
<ErrorMessageBlockComponent nodeKey="node-1" />
</div>
</LexicalComposerContext.Provider>,
)
await user.click(screen.getByText('error_message'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
describe('Hooks', () => {
it('should use selection hook and check node registration on mount', () => {
renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-xyz" />)
expect(useSelectOrDelete).toHaveBeenCalledWith('node-xyz', DELETE_ERROR_MESSAGE_COMMAND)
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
})
it('should throw when ErrorMessageBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)).toThrow(
'WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor',
)
})
})
})

View File

@@ -0,0 +1,125 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { EntityMatch } from '@lexical/text'
import type { LexicalEditor, LexicalNode } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $applyNodeReplacement } from 'lexical'
import { ERROR_MESSAGE_PLACEHOLDER_TEXT } from '../../constants'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import ErrorMessageBlockReplacementBlock from './error-message-block-replacement-block'
import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
vi.mock('@lexical/utils')
vi.mock('lexical')
vi.mock('../../utils')
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterNodeTransform = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
registerNodeTransform: mockRegisterNodeTransform,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('ErrorMessageBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterNodeTransform.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
return () => cleanups.forEach(cleanup => cleanup())
})
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ type: 'node' } as unknown as ErrorMessageBlockNode)
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
})
it('should register transform and cleanup on unmount', () => {
const transformCleanup = vi.fn()
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
const { unmount, container } = renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
unmount()
expect(transformCleanup).toHaveBeenCalled()
})
it('should throw when ErrorMessageBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)).toThrow(
'ErrorMessageBlockNodePlugin: ErrorMessageBlockNode not registered on editor',
)
})
it('should pass matcher and creator to decoratorTransform and match placeholder text', () => {
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
const textNode = { id: 't-1' } as unknown as LexicalNode
transformCallback(textNode)
expect(decoratorTransform).toHaveBeenCalledWith(
textNode,
expect.any(Function),
expect.any(Function),
)
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
const match = getMatch(`hello ${ERROR_MESSAGE_PLACEHOLDER_TEXT} world`)
expect(match).toEqual({
start: 6,
end: 6 + ERROR_MESSAGE_PLACEHOLDER_TEXT.length,
})
expect(getMatch('hello world')).toBeNull()
})
it('should create replacement node and call onInsert when creator runs', () => {
const onInsert = vi.fn()
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock onInsert={onInsert} />)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 't-1' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
const created = createNode()
expect(onInsert).toHaveBeenCalledTimes(1)
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'node' })
expect(created).toEqual({ type: 'node' })
})
it('should create replacement node without onInsert callback', () => {
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 't-1' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
expect(() => createNode()).not.toThrow()
expect($createErrorMessageBlockNode).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,143 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
import {
DELETE_ERROR_MESSAGE_COMMAND,
ErrorMessageBlock,
ErrorMessageBlockNode,
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
} from './index'
import { $createErrorMessageBlockNode } from './node'
vi.mock('@lexical/utils')
vi.mock('lexical', async () => {
const actual = await vi.importActual('lexical')
return {
...actual,
$insertNodes: vi.fn(),
createCommand: vi.fn(name => name),
COMMAND_PRIORITY_EDITOR: 1,
}
})
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterCommand = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
registerCommand: mockRegisterCommand,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('ErrorMessageBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterCommand.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
return () => cleanups.forEach(cleanup => cleanup())
})
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ id: 'node' } as unknown as ErrorMessageBlockNode)
})
it('should render null and register insert and delete commands', () => {
const { container } = renderWithLexicalContext(<ErrorMessageBlock />)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
expect(mockRegisterCommand).toHaveBeenCalledTimes(2)
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
1,
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
2,
DELETE_ERROR_MESSAGE_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(ErrorMessageBlock.displayName).toBe('ErrorMessageBlock')
})
it('should throw when ErrorMessageBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<ErrorMessageBlock />)).toThrow(
'ERROR_MESSAGEBlockPlugin: ERROR_MESSAGEBlock not registered on editor',
)
})
it('should insert created node and call onInsert when insert command handler runs', () => {
const onInsert = vi.fn()
renderWithLexicalContext(<ErrorMessageBlock onInsert={onInsert} />)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
const result = insertHandler()
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
expect($insertNodes).toHaveBeenCalledWith([{ id: 'node' }])
expect(onInsert).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on insert command without onInsert callback', () => {
renderWithLexicalContext(<ErrorMessageBlock />)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
expect(insertHandler()).toBe(true)
expect($insertNodes).toHaveBeenCalled()
})
it('should call onDelete and return true when delete command handler runs', () => {
const onDelete = vi.fn()
renderWithLexicalContext(<ErrorMessageBlock onDelete={onDelete} />)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
const result = deleteHandler()
expect(onDelete).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on delete command without onDelete callback', () => {
renderWithLexicalContext(<ErrorMessageBlock />)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
expect(deleteHandler()).toBe(true)
})
it('should run merged cleanup on unmount', () => {
const insertCleanup = vi.fn()
const deleteCleanup = vi.fn()
mockRegisterCommand
.mockReturnValueOnce(insertCleanup)
.mockReturnValueOnce(deleteCleanup)
const { unmount } = renderWithLexicalContext(<ErrorMessageBlock />)
unmount()
expect(insertCleanup).toHaveBeenCalledTimes(1)
expect(deleteCleanup).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,86 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import { createEditor } from 'lexical'
import { $createErrorMessageBlockNode, $isErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
describe('ErrorMessageBlockNode', () => {
let editor: LexicalEditor
beforeEach(() => {
vi.clearAllMocks()
editor = createEditor({
nodes: [ErrorMessageBlockNode as unknown as Klass<LexicalNode>],
})
})
const runInEditor = (callback: () => void) => {
editor.update(callback, { discrete: true })
}
it('should expose correct static type and clone behavior', () => {
runInEditor(() => {
const original = new ErrorMessageBlockNode('node-key')
const cloned = ErrorMessageBlockNode.clone(original)
expect(ErrorMessageBlockNode.getType()).toBe('error-message-block')
expect(cloned).toBeInstanceOf(ErrorMessageBlockNode)
expect(cloned).not.toBe(original)
expect(cloned.getKey()).toBe(original.getKey())
})
})
it('should be inline and provide expected text and json payload', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#error_message#}}')
expect(node.exportJSON()).toEqual({
type: 'error-message-block',
version: 1,
})
})
})
it('should create dom with expected classes and never update dom', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
const dom = node.createDOM()
expect(dom.tagName).toBe('DIV')
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('align-middle')
expect(node.updateDOM()).toBe(false)
})
})
it('should decorate using ErrorMessageBlockComponent with node key', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode('decorator-key')
const decorated = node.decorate()
expect(decorated.props.nodeKey).toBe('decorator-key')
})
})
it('should create and import node instances via helper APIs', () => {
runInEditor(() => {
const created = $createErrorMessageBlockNode()
const imported = ErrorMessageBlockNode.importJSON()
expect(created).toBeInstanceOf(ErrorMessageBlockNode)
expect(imported).toBeInstanceOf(ErrorMessageBlockNode)
})
})
it('should return correct type guard values for lexical and non lexical inputs', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
expect($isErrorMessageBlockNode(node)).toBe(true)
expect($isErrorMessageBlockNode(null)).toBe(false)
expect($isErrorMessageBlockNode(undefined)).toBe(false)
expect($isErrorMessageBlockNode({} as ErrorMessageBlockNode)).toBe(false)
})
})
})

View File

@@ -0,0 +1,205 @@
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { RoleName } from './index'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
import HistoryBlockComponent from './component'
import { DELETE_HISTORY_BLOCK_COMMAND } from './index'
type HistoryEventPayload = {
type?: string
payload?: RoleName
}
type HistorySubscriptionHandler = (payload: HistoryEventPayload) => void
const { mockUseSelectOrDelete, mockUseTrigger, mockUseEventEmitterContextContext } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
mockUseTrigger: vi.fn(),
mockUseEventEmitterContextContext: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
useTrigger: (...args: unknown[]) => mockUseTrigger(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const createSelectHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
const createTriggerHookReturn = (
open: boolean,
setOpen: Dispatch<SetStateAction<boolean>> = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>,
): [RefObject<HTMLDivElement | null>, boolean, Dispatch<SetStateAction<boolean>>] => {
return [{ current: null }, open, setOpen]
}
describe('HistoryBlockComponent', () => {
let subscribedHandler: HistorySubscriptionHandler | null
beforeEach(() => {
vi.clearAllMocks()
subscribedHandler = null
mockUseSelectOrDelete.mockReturnValue(createSelectHookReturn(false))
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false))
const subscribeToHistoryEvents = (handler: HistorySubscriptionHandler) => {
subscribedHandler = handler
}
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: {
useSubscription: subscribeToHistoryEvents,
},
})
})
it('should render title and register select or delete hook with node key', () => {
render(
<HistoryBlockComponent
nodeKey="history-node-1"
onEditRole={vi.fn()}
/>,
)
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('history-node-1', DELETE_HISTORY_BLOCK_COMMAND)
expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument()
})
it('should apply selected and opened classes when selected and popup is open', () => {
mockUseSelectOrDelete.mockReturnValue(createSelectHookReturn(true))
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
const { container } = render(
<HistoryBlockComponent
nodeKey="history-node-2"
onEditRole={vi.fn()}
/>,
)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('!border-[#F670C7]')
expect(wrapper).toHaveClass('bg-[#FCE7F6]')
})
it('should render modal content when popup is open', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-3"
roleName={createRoleName()}
onEditRole={vi.fn()}
/>,
)
expect(screen.getByText('user-role')).toBeInTheDocument()
expect(screen.getByText('assistant-role')).toBeInTheDocument()
expect(screen.getByText('common.promptEditor.history.modal.user')).toBeInTheDocument()
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
})
it('should call onEditRole when edit action is clicked', async () => {
const user = userEvent.setup()
const onEditRole = vi.fn()
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-4"
roleName={createRoleName()}
onEditRole={onEditRole}
/>,
)
await user.click(screen.getByText('common.promptEditor.history.modal.edit'))
expect(onEditRole).toHaveBeenCalledTimes(1)
})
it('should update local role names when update history event is received', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-5"
roleName={createRoleName({
user: 'old-user',
assistant: 'old-assistant',
})}
onEditRole={vi.fn()}
/>,
)
expect(screen.getByText('old-user')).toBeInTheDocument()
expect(screen.getByText('old-assistant')).toBeInTheDocument()
expect(subscribedHandler).not.toBeNull()
act(() => {
subscribedHandler?.({
type: UPDATE_HISTORY_EVENT_EMITTER,
payload: {
user: 'new-user',
assistant: 'new-assistant',
},
})
})
expect(screen.getByText('new-user')).toBeInTheDocument()
expect(screen.getByText('new-assistant')).toBeInTheDocument()
})
it('should ignore non history update events from event emitter', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-6"
roleName={createRoleName({
user: 'kept-user',
assistant: 'kept-assistant',
})}
onEditRole={vi.fn()}
/>,
)
expect(subscribedHandler).not.toBeNull()
act(() => {
subscribedHandler?.({
type: 'other-event',
payload: {
user: 'updated-user',
assistant: 'updated-assistant',
},
})
})
expect(screen.getByText('kept-user')).toBeInTheDocument()
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
})
it('should render when event emitter is unavailable', () => {
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: undefined,
})
render(
<HistoryBlockComponent
nodeKey="history-node-7"
onEditRole={vi.fn()}
/>,
)
expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,118 @@
import type { LexicalEditor } from 'lexical'
import type { RoleName } from './index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { HISTORY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import HistoryBlockReplacementBlock from './history-block-replacement-block'
import { HistoryBlockNode } from './node'
const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const renderReplacementPlugin = (props?: {
history?: RoleName
onEditRole?: () => void
onInsert?: () => void
}) => {
return renderLexicalEditor({
namespace: 'history-block-replacement-plugin-test',
nodes: [CustomTextNode, HistoryBlockNode],
children: (
<HistoryBlockReplacementBlock
history={props?.history}
onEditRole={props?.onEditRole}
onInsert={props?.onInsert}
/>
),
})
}
const getFirstNodeRoleName = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
const node = $nodesOfType(HistoryBlockNode)[0]
return node?.getRoleName() ?? null
})
}
describe('HistoryBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should replace history placeholder and call onInsert', async () => {
const onInsert = vi.fn()
const history = createRoleName()
const onEditRole = vi.fn()
const { getEditor } = renderReplacementPlugin({
onInsert,
history,
onEditRole,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${HISTORY_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
expect(getFirstNodeRoleName(editor)).toEqual(history)
})
it('should not replace text when history placeholder is absent', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without history placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, HistoryBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace history placeholder without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, HISTORY_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
})
})
it('should throw when history node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'history-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HistoryBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('HistoryBlockNodePlugin: HistoryBlockNode not registered on editor')
})
})

View File

@@ -0,0 +1,172 @@
import type { LexicalEditor } from 'lexical'
import type { RoleName } from './index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { HISTORY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_HISTORY_BLOCK_COMMAND,
HistoryBlock,
HistoryBlockNode,
INSERT_HISTORY_BLOCK_COMMAND,
} from './index'
const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const renderHistoryBlock = (props?: {
history?: RoleName
onEditRole?: () => void
onInsert?: () => void
onDelete?: () => void
}) => {
return renderLexicalEditor({
namespace: 'history-block-plugin-test',
nodes: [CustomTextNode, HistoryBlockNode],
children: (
<HistoryBlock
history={props?.history}
onEditRole={props?.onEditRole}
onInsert={props?.onInsert}
onDelete={props?.onDelete}
/>
),
})
}
const getFirstNodeRoleName = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
const node = $nodesOfType(HistoryBlockNode)[0]
return node?.getRoleName() ?? null
})
}
describe('HistoryBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should insert history block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const onEditRole = vi.fn()
const history = createRoleName()
const { getEditor } = renderHistoryBlock({ onInsert, onEditRole, history })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(HISTORY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
expect(getFirstNodeRoleName(editor)).toEqual(history)
})
it('should insert history block with default props when insert command is dispatched', async () => {
const { getEditor } = renderHistoryBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(HISTORY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
expect(getFirstNodeRoleName(editor)).toEqual({
user: '',
assistant: '',
})
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderHistoryBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderHistoryBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderHistoryBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_HISTORY_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when history node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'history-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HistoryBlock />
</LexicalComposer>,
)
}).toThrow('HistoryBlockPlugin: HistoryBlock not registered on editor')
})
})

View File

@@ -0,0 +1,168 @@
import type { SerializedNode as SerializedHistoryBlockNode } from './node'
import { act } from '@testing-library/react'
import { $getNodeByKey, $getRoot } from 'lexical'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import HistoryBlockComponent from './component'
import {
$createHistoryBlockNode,
$isHistoryBlockNode,
HistoryBlockNode,
} from './node'
const createRoleName = (overrides?: { user?: string, assistant?: string }) => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const createTestEditor = () => {
return createLexicalTestEditor('history-block-node-test', [HistoryBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
const roleName = createRoleName()
const onEditRole = vi.fn()
let node!: HistoryBlockNode
let nodeKey = ''
act(() => {
editor.update(() => {
node = $createHistoryBlockNode(roleName, onEditRole)
$getRoot().append(node)
nodeKey = node.getKey()
})
})
return { editor, node, nodeKey, roleName, onEditRole }
}
describe('HistoryBlockNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose history block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(HistoryBlockNode.getType()).toBe('history-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#histories#}}')
})
it('should clone into a new history block node with same role and handler', () => {
const { editor, node, nodeKey } = createNodeInEditor()
let cloned!: HistoryBlockNode
act(() => {
editor.update(() => {
const currentNode = $getNodeByKey(nodeKey) as HistoryBlockNode
cloned = HistoryBlockNode.clone(currentNode)
})
})
expect(cloned).toBeInstanceOf(HistoryBlockNode)
expect(cloned).not.toBe(node)
})
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
it('should decorate with history block component and expected props', () => {
const { editor, nodeKey, roleName, onEditRole } = createNodeInEditor()
let element!: React.JSX.Element
act(() => {
editor.update(() => {
const currentNode = $getNodeByKey(nodeKey) as HistoryBlockNode
element = currentNode.decorate()
})
})
expect(element.type).toBe(HistoryBlockComponent)
expect(element.props.nodeKey).toBe(nodeKey)
expect(element.props.roleName).toEqual(roleName)
expect(element.props.onEditRole).toBe(onEditRole)
})
it('should export and import JSON with role and edit handler', () => {
const { editor, nodeKey, roleName, onEditRole } = createNodeInEditor()
let serialized!: SerializedHistoryBlockNode
let imported!: HistoryBlockNode
let importedKey = ''
const payload: SerializedHistoryBlockNode = {
type: 'history-block',
version: 1,
roleName,
onEditRole,
}
act(() => {
editor.update(() => {
const currentNode = $getNodeByKey(nodeKey) as HistoryBlockNode
serialized = currentNode.exportJSON()
})
})
act(() => {
editor.update(() => {
imported = HistoryBlockNode.importJSON(payload)
$getRoot().append(imported)
importedKey = imported.getKey()
expect(imported.getRoleName()).toEqual(roleName)
expect(imported.getOnEditRole()).toBe(onEditRole)
})
})
expect(serialized.type).toBe('history-block')
expect(serialized.version).toBe(1)
expect(serialized.roleName).toEqual(roleName)
expect(typeof serialized.onEditRole).toBe('function')
expect(imported).toBeInstanceOf(HistoryBlockNode)
expect(importedKey).not.toBe('')
})
it('should identify history block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isHistoryBlockNode(node)).toBe(true)
expect($isHistoryBlockNode(null)).toBe(false)
expect($isHistoryBlockNode(undefined)).toBe(false)
})
it('should create a history block node instance from factory', () => {
const editor = createTestEditor()
const roleName = createRoleName({
user: 'custom-user',
assistant: 'custom-assistant',
})
const onEditRole = vi.fn()
let node!: HistoryBlockNode
act(() => {
editor.update(() => {
node = $createHistoryBlockNode(roleName, onEditRole)
expect(node.getRoleName()).toEqual(roleName)
expect(node.getOnEditRole()).toBe(onEditRole)
})
})
expect(node).toBeInstanceOf(HistoryBlockNode)
})
})

View File

@@ -0,0 +1,153 @@
import type { RefObject } from 'react'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import HITLInputComponent from './component'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
vi.mock('./component-ui', () => ({
default: ({ formInput, onChange }: { formInput?: FormInputItem, onChange: (payload: FormInputItem) => void }) => {
const basePayload: FormInputItem = formInput ?? {
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
}
return (
<div>
<button
type="button"
onClick={() => onChange(basePayload)}
>
emit-same-name
</button>
<button
type="button"
onClick={() => onChange({
...basePayload,
output_variable_name: 'renamed_name',
})}
>
emit-rename
</button>
<button
type="button"
onClick={() => onChange({
...basePayload,
default: {
type: 'constant',
selector: [],
value: 'updated',
},
})}
>
emit-update
</button>
</div>
)
},
}))
const createHookReturn = (): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, false]
}
const createInput = (overrides?: Partial<FormInputItem>): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
...overrides,
})
describe('HITLInputComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSelectOrDelete.mockReturnValue(createHookReturn())
})
it('should append payload when matching form input does not exist', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-1"
nodeId="node-1"
varName="user_name"
formInputs={[]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-same-name' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toHaveLength(1)
expect(onChange.mock.calls[0][0][0].output_variable_name).toBe('user_name')
})
it('should replace payload when variable name is renamed', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-2"
nodeId="node-2"
varName="user_name"
formInputs={[createInput()]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-rename' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0][0].output_variable_name).toBe('renamed_name')
})
it('should update existing payload when variable name stays the same', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-3"
nodeId="node-3"
varName="user_name"
formInputs={[createInput()]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-update' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0][0].default.value).toBe('updated')
expect(onChange.mock.calls[0][0][0].output_variable_name).toBe('user_name')
})
})

View File

@@ -0,0 +1,250 @@
import type { LexicalEditor } from 'lexical'
import type { GetVarType } from '../../types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import {
BlockEnum,
InputVarType,
} from '@/app/components/workflow/types'
import { CustomTextNode } from '../custom-text/node'
import {
getNodesByType,
readEditorStateValue,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import HITLInputReplacementBlock from './hitl-input-block-replacement-block'
import { HITLInputNode } from './node'
const createWorkflowNodesMap = () => ({
'node-1': {
title: 'Start Node',
type: BlockEnum.Start,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
})
const createFormInput = (): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
})
const createVariables = (): NodeOutPutVar[] => {
return [
{
nodeId: 'env',
title: 'Env',
vars: [{ variable: 'env.api_key', type: 'string' } as Var],
},
{
nodeId: 'conversation',
title: 'Conversation',
vars: [{ variable: 'conversation.user_id', type: 'number' } as Var],
},
{
nodeId: 'rag',
title: 'RAG',
vars: [{ variable: 'rag.shared.file_name', type: 'string', isRagVariable: true } as Var],
},
{
nodeId: 'node-1',
title: 'Node 1',
vars: [
{ variable: 'node-1.ignore_me', type: 'string', isRagVariable: false } as Var,
{ variable: 'node-1.doc_name', type: 'string', isRagVariable: true } as Var,
],
},
]
}
const renderReplacementPlugin = (props?: {
variables?: NodeOutPutVar[]
readonly?: boolean
getVarType?: GetVarType
formInputs?: FormInputItem[] | null
}) => {
const formInputs = props?.formInputs === null ? undefined : (props?.formInputs ?? [createFormInput()])
return renderLexicalEditor({
namespace: 'hitl-input-replacement-plugin-test',
nodes: [CustomTextNode, HITLInputNode],
children: (
<HITLInputReplacementBlock
nodeId="node-1"
formInputs={formInputs}
onFormInputsChange={vi.fn()}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap()}
variables={props?.variables}
getVarType={props?.getVarType}
readonly={props?.readonly}
/>
),
})
}
type HITLInputNodeSnapshot = {
variableName: string
nodeId: string
getVarType: GetVarType | undefined
readonly: boolean
environmentVariables: Var[]
conversationVariables: Var[]
ragVariables: Var[]
formInputsLength: number
}
const readFirstHITLInputNodeSnapshot = (editor: LexicalEditor): HITLInputNodeSnapshot | null => {
return readEditorStateValue(editor, () => {
const node = $nodesOfType(HITLInputNode)[0]
if (!node)
return null
return {
variableName: node.getVariableName(),
nodeId: node.getNodeId(),
getVarType: node.getGetVarType(),
readonly: node.getReadonly(),
environmentVariables: node.getEnvironmentVariables(),
conversationVariables: node.getConversationVariables(),
ragVariables: node.getRagVariables(),
formInputsLength: node.getFormInputs().length,
}
})
}
describe('HITLInputReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace matched output token with hitl input node and map variables from all supported sources', async () => {
const getVarType: GetVarType = () => Type.string
const { getEditor } = renderReplacementPlugin({
variables: createVariables(),
readonly: true,
getVarType,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'before {{#$output.user_name#}} after', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1)
})
const node = readFirstHITLInputNodeSnapshot(editor)
expect(node).not.toBeNull()
if (!node)
throw new Error('Expected HITLInputNode snapshot')
expect(node.variableName).toBe('user_name')
expect(node.nodeId).toBe('node-1')
expect(node.getVarType).toBe(getVarType)
expect(node.readonly).toBe(true)
expect(node.environmentVariables).toEqual([{ variable: 'env.api_key', type: 'string' }])
expect(node.conversationVariables).toEqual([{ variable: 'conversation.user_id', type: 'number' }])
expect(node.ragVariables).toEqual([
{ variable: 'rag.shared.file_name', type: 'string', isRagVariable: true },
{ variable: 'node-1.doc_name', type: 'string', isRagVariable: true },
])
})
it('should not replace text when no hitl output token exists', async () => {
const { getEditor } = renderReplacementPlugin({
variables: createVariables(),
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without replacement token', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(0)
})
})
it('should replace token with empty env conversation and rag lists when variables are not provided', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, '{{#$output.user_name#}}', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1)
})
const node = readFirstHITLInputNodeSnapshot(editor)
expect(node).not.toBeNull()
if (!node)
throw new Error('Expected HITLInputNode snapshot')
expect(node.environmentVariables).toEqual([])
expect(node.conversationVariables).toEqual([])
expect(node.ragVariables).toEqual([])
expect(node.readonly).toBe(false)
})
it('should replace token with empty form inputs when formInputs is undefined', async () => {
const { getEditor } = renderReplacementPlugin({ formInputs: null })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, '{{#$output.user_name#}}', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1)
})
const node = readFirstHITLInputNodeSnapshot(editor)
expect(node).not.toBeNull()
if (!node)
throw new Error('Expected HITLInputNode snapshot')
expect(node.formInputsLength).toBe(0)
})
})
describe('Node registration guard', () => {
it('should throw when hitl input node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HITLInputReplacementBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputsChange={vi.fn()}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap()}
/>
</LexicalComposer>,
)
}).toThrow('HITLInputNodePlugin: HITLInputNode not registered on editor')
})
})
})

View File

@@ -0,0 +1,241 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, render, waitFor } from '@testing-library/react'
import {
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import { useEffect } from 'react'
import {
BlockEnum,
InputVarType,
} from '@/app/components/workflow/types'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_HITL_INPUT_BLOCK_COMMAND,
HITLInputBlock,
HITLInputNode,
INSERT_HITL_INPUT_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP,
} from './index'
type UpdateWorkflowNodesMapPluginProps = {
onUpdate: (payload: unknown) => void
}
const UpdateWorkflowNodesMapPlugin = ({ onUpdate }: UpdateWorkflowNodesMapPluginProps) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(payload: unknown) => {
onUpdate(payload)
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, onUpdate])
return null
}
const createWorkflowNodesMap = (title: string) => ({
'node-1': {
title,
type: BlockEnum.Start,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
})
const createFormInput = (): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
})
const createInsertPayload = () => ({
variableName: 'user_name',
nodeId: 'node-1',
formInputs: [createFormInput()],
onFormInputsChange: vi.fn(),
onFormInputItemRename: vi.fn(),
onFormInputItemRemove: vi.fn(),
})
const renderHITLInputBlock = (props?: {
onInsert?: () => void
onDelete?: () => void
workflowNodesMap?: ReturnType<typeof createWorkflowNodesMap>
onWorkflowMapUpdate?: (payload: unknown) => void
}) => {
const workflowNodesMap = props?.workflowNodesMap ?? createWorkflowNodesMap('First Node')
return renderLexicalEditor({
namespace: 'hitl-input-block-plugin-test',
nodes: [CustomTextNode, HITLInputNode],
children: (
<>
{props?.onWorkflowMapUpdate && <UpdateWorkflowNodesMapPlugin onUpdate={props.onWorkflowMapUpdate} />}
<HITLInputBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={workflowNodesMap}
onInsert={props?.onInsert}
onDelete={props?.onDelete}
/>
</>
),
})
}
describe('HITLInputBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Workflow map command dispatch', () => {
it('should dispatch UPDATE_WORKFLOW_NODES_MAP when mounted', async () => {
const onWorkflowMapUpdate = vi.fn()
const workflowNodesMap = createWorkflowNodesMap('Map Node')
renderHITLInputBlock({
workflowNodesMap,
onWorkflowMapUpdate,
})
await waitFor(() => {
expect(onWorkflowMapUpdate).toHaveBeenCalledWith(workflowNodesMap)
})
})
})
describe('Command handling', () => {
it('should insert hitl input block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderHITLInputBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toContain('{{#$output.user_name#}}')
})
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
})
it('should insert hitl input block without onInsert callback', async () => {
const { getEditor } = renderHITLInputBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toContain('{{#$output.user_name#}}')
})
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderHITLInputBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderHITLInputBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderHITLInputBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
deleteHandled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when hitl input node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HITLInputBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap('Map Node')}
/>
</LexicalComposer>,
)
}).toThrow('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,277 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import InputField from './input-field'
type VarReferencePickerProps = {
onChange: (value: string[]) => void
}
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: (props: VarReferencePickerProps) => {
return (
<button type="button" onClick={() => props.onChange(['node-a', 'var-a'])}>
pick-variable
</button>
)
},
}))
const createPayload = (overrides?: Partial<FormInputItem>): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'valid_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
...overrides,
})
describe('InputField', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should disable save and show validation error when variable name is invalid', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-1"
isEdit
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
const inputs = screen.getAllByRole('textbox')
await user.clear(inputs[0])
await user.type(inputs[0], 'invalid name')
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameInvalid')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onChange).not.toHaveBeenCalled()
})
it('should call onChange when saving a valid payload in edit mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-2"
isEdit
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toEqual(createPayload())
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<InputField
nodeId="node-3"
isEdit={false}
payload={createPayload()}
onChange={vi.fn()}
onCancel={onCancel}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should use default payload when payload is not provided', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-default-payload"
isEdit={false}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
const nameInput = screen.getAllByRole('textbox')[0]
await user.type(nameInput, 'generated_name')
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toEqual({
type: InputVarType.paragraph,
output_variable_name: 'generated_name',
default: {
type: 'constant',
selector: [],
value: '',
},
})
})
it('should save in create mode on Ctrl+Enter and include updated default constant value', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-4"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: '',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.keyboard('{Tab}')
const inputs = screen.getAllByRole('textbox')
await user.type(inputs[1], 'constant-default')
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default).toEqual({
type: 'constant',
selector: [],
value: 'constant-default',
})
})
it('should switch to variable mode when type switch is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-4-1"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: 'preset',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default.type).toBe('variable')
})
it('should switch to constant mode when variable mode type switch is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-5-1"
isEdit={false}
payload={createPayload({
default: {
type: 'variable',
selector: ['node-y', 'var-y'],
value: '',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default.type).toBe('constant')
})
it('should update default selector when variable picker is used', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-5"
isEdit={false}
payload={createPayload({
default: {
type: 'variable',
selector: ['node-x', 'old'],
value: '',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByText('pick-variable'))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default).toEqual({
type: 'variable',
selector: ['node-a', 'var-a'],
value: '',
})
})
it('should initialize default config when missing and selector is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const payloadWithoutDefault = {
...createPayload(),
default: undefined,
} as unknown as FormInputItem
render(
<InputField
nodeId="node-6"
isEdit={false}
payload={payloadWithoutDefault}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.keyboard('{Tab}')
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default).toEqual({
type: 'variable',
selector: [],
value: '',
})
})
})

View File

@@ -0,0 +1,235 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Var } from '@/app/components/workflow/types'
import { act } from '@testing-library/react'
import {
BlockEnum,
InputVarType,
} from '@/app/components/workflow/types'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import HITLInputBlockComponent from './component'
import {
$createHITLInputNode,
$isHITLInputNode,
HITLInputNode,
} from './node'
const createFormInput = (): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
})
const createNodeProps = () => {
return {
variableName: 'user_name',
nodeId: 'node-1',
formInputs: [createFormInput()],
onFormInputsChange: vi.fn(),
onFormInputItemRename: vi.fn(),
onFormInputItemRemove: vi.fn(),
workflowNodesMap: {
'node-1': {
title: 'Node 1',
type: BlockEnum.Start,
height: 100,
width: 100,
position: { x: 0, y: 0 },
},
},
getVarType: vi.fn(),
environmentVariables: [{ variable: 'env.var_a', type: 'string' }] as Var[],
conversationVariables: [{ variable: 'conversation.var_b', type: 'number' }] as Var[],
ragVariables: [{ variable: 'rag.shared.var_c', type: 'string', isRagVariable: true }] as Var[],
readonly: true,
}
}
const createTestEditor = () => {
return createLexicalTestEditor('hitl-input-node-test', [HITLInputNode])
}
describe('HITLInputNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose node metadata and configured properties through getters', () => {
const editor = createTestEditor()
const props = createNodeProps()
expect(HITLInputNode.getType()).toBe('hitl-input-block')
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
expect(node.isInline()).toBe(true)
expect(node.isIsolated()).toBe(true)
expect(node.isTopLevel()).toBe(true)
expect(node.getVariableName()).toBe(props.variableName)
expect(node.getNodeId()).toBe(props.nodeId)
expect(node.getFormInputs()).toEqual(props.formInputs)
expect(node.getOnFormInputsChange()).toBe(props.onFormInputsChange)
expect(node.getOnFormInputItemRename()).toBe(props.onFormInputItemRename)
expect(node.getOnFormInputItemRemove()).toBe(props.onFormInputItemRemove)
expect(node.getWorkflowNodesMap()).toEqual(props.workflowNodesMap)
expect(node.getGetVarType()).toBe(props.getVarType)
expect(node.getEnvironmentVariables()).toEqual(props.environmentVariables)
expect(node.getConversationVariables()).toEqual(props.conversationVariables)
expect(node.getRagVariables()).toEqual(props.ragVariables)
expect(node.getReadonly()).toBe(true)
expect(node.getTextContent()).toBe('{{#$output.user_name#}}')
})
})
})
it('should return default fallback values for optional properties', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
)
expect(node.getEnvironmentVariables()).toEqual([])
expect(node.getConversationVariables()).toEqual([])
expect(node.getRagVariables()).toEqual([])
expect(node.getReadonly()).toBe(false)
})
})
})
it('should clone, serialize, import and decorate correctly', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
const serialized = node.exportJSON()
const cloned = HITLInputNode.clone(node)
const imported = HITLInputNode.importJSON(serialized)
expect(cloned).toBeInstanceOf(HITLInputNode)
expect(cloned.getKey()).toBe(node.getKey())
expect(cloned).not.toBe(node)
expect(imported).toBeInstanceOf(HITLInputNode)
const element = node.decorate()
expect(element.type).toBe(HITLInputBlockComponent)
expect(element.props.nodeKey).toBe(node.getKey())
expect(element.props.varName).toBe('user_name')
})
})
})
it('should fallback to empty form inputs when imported payload omits formInputs', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const source = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
const payload = {
...source.exportJSON(),
formInputs: undefined as unknown as FormInputItem[],
}
const imported = HITLInputNode.importJSON(payload)
const cloned = HITLInputNode.clone(imported)
expect(imported.getFormInputs()).toEqual([])
expect(cloned.getFormInputs()).toEqual([])
})
})
})
it('should create and update DOM and support helper type guard', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
const dom = node.createDOM()
expectInlineWrapperDom(dom, ['w-[calc(100%-1px)]', 'support-drag'])
expect(node.updateDOM()).toBe(false)
expect($isHITLInputNode(node)).toBe(true)
})
})
expect($isHITLInputNode(null)).toBe(false)
expect($isHITLInputNode(undefined)).toBe(false)
})
})

View File

@@ -0,0 +1,126 @@
import type { Var } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import PrePopulate from './pre-populate'
const { mockVarReferencePicker } = vi.hoisted(() => ({
mockVarReferencePicker: vi.fn(),
}))
type VarReferencePickerProps = {
onChange: (value: string[]) => void
filterVar: (v: Var) => boolean
}
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: (props: VarReferencePickerProps) => {
mockVarReferencePicker(props)
return (
<button type="button" onClick={() => props.onChange(['node-1', 'var-1'])}>
pick-variable
</button>
)
},
}))
describe('PrePopulate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show placeholder initially and switch out of placeholder on Tab key', async () => {
const user = userEvent.setup()
render(
<PrePopulate
nodeId="node-1"
isVariable={false}
value=""
/>,
)
expect(screen.getByText('nodes.humanInput.insertInputField.prePopulateFieldPlaceholder')).toBeInTheDocument()
await user.keyboard('{Tab}')
expect(screen.queryByText('nodes.humanInput.insertInputField.prePopulateFieldPlaceholder')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should update constant value and toggle to variable mode when type switch is clicked', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
const onIsVariableChange = vi.fn()
const Wrapper = () => {
const [value, setValue] = useState('initial value')
return (
<PrePopulate
nodeId="node-1"
isVariable={false}
value={value}
onValueChange={(next) => {
onValueChange(next)
setValue(next)
}}
onIsVariableChange={onIsVariableChange}
/>
)
}
render(
<Wrapper />,
)
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'next')
await user.click(screen.getByText('workflow.nodes.humanInput.insertInputField.useVarInstead'))
expect(onValueChange).toHaveBeenLastCalledWith('next')
expect(onIsVariableChange).toHaveBeenCalledWith(true)
})
it('should render variable picker mode and propagate selected value selector', async () => {
const user = userEvent.setup()
const onValueSelectorChange = vi.fn()
const onIsVariableChange = vi.fn()
render(
<PrePopulate
nodeId="node-2"
isVariable
valueSelector={['node-2', 'existing']}
onValueSelectorChange={onValueSelectorChange}
onIsVariableChange={onIsVariableChange}
/>,
)
await user.click(screen.getByText('pick-variable'))
await user.click(screen.getByText('workflow.nodes.humanInput.insertInputField.useConstantInstead'))
expect(onValueSelectorChange).toHaveBeenCalledWith(['node-1', 'var-1'])
expect(onIsVariableChange).toHaveBeenCalledWith(false)
})
it('should pass variable type filter to picker that allows string number and secret', () => {
render(
<PrePopulate
nodeId="node-3"
isVariable
valueSelector={['node-3', 'existing']}
/>,
)
const pickerProps = mockVarReferencePicker.mock.calls[0][0] as VarReferencePickerProps
const allowString = pickerProps.filterVar({ type: 'string' } as Var)
const allowNumber = pickerProps.filterVar({ type: 'number' } as Var)
const allowSecret = pickerProps.filterVar({ type: 'secret' } as Var)
const blockObject = pickerProps.filterVar({ type: 'object' } as Var)
expect(allowString).toBe(true)
expect(allowNumber).toBe(true)
expect(allowSecret).toBe(true)
expect(blockObject).toBe(false)
})
})

View File

@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagLabel from './tag-label'
describe('TagLabel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render edit icon label and trigger click handler when type is edit', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const { container } = render(
<TagLabel type="edit" onClick={onClick}>
Edit
</TagLabel>,
)
await user.click(screen.getByText('Edit'))
expect(onClick).toHaveBeenCalledTimes(1)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render variable icon label when type is variable', () => {
const { container } = render(
<TagLabel type="variable">
Variable
</TagLabel>,
)
expect(screen.getByText('Variable')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TypeSwitch from './type-switch'
describe('TypeSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render use variable text when isVariable is false and toggle to true on click', async () => {
const user = userEvent.setup()
const onIsVariableChange = vi.fn()
render(
<TypeSwitch isVariable={false} onIsVariableChange={onIsVariableChange} />,
)
const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useVarInstead')
await user.click(trigger)
expect(onIsVariableChange).toHaveBeenCalledWith(true)
})
it('should render use constant text when isVariable is true and toggle to false on click', async () => {
const user = userEvent.setup()
const onIsVariableChange = vi.fn()
render(
<TypeSwitch isVariable onIsVariableChange={onIsVariableChange} />,
)
const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useConstantInstead')
await user.click(trigger)
expect(onIsVariableChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,208 @@
import type { LexicalEditor } from 'lexical'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { Var } from '@/app/components/workflow/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, screen, waitFor } from '@testing-library/react'
import {
$getRoot,
} from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import { CaptureEditorPlugin } from '../test-utils'
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
import { HITLInputNode } from './node'
import HITLInputVariableBlockComponent from './variable-block'
const createWorkflowNodesMap = (title = 'Node One'): WorkflowNodesMap => ({
'node-1': {
title,
type: BlockEnum.LLM,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
'node-rag': {
title: 'Retriever',
type: BlockEnum.LLM,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
})
const hasErrorIcon = (container: HTMLElement) => {
return container.querySelector('svg.text-text-destructive') !== null
}
const renderVariableBlock = (props: {
variables: string[]
workflowNodesMap?: WorkflowNodesMap
getVarType?: (payload: { nodeId: string, valueSelector: string[] }) => Type
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
}) => {
let editor: LexicalEditor | null = null
const setEditor = (value: LexicalEditor) => {
editor = value
}
const utils = render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-variable-block-test',
onError: (error: Error) => {
throw error
},
nodes: [HITLInputNode],
}}
>
<HITLInputVariableBlockComponent
variables={props.variables}
workflowNodesMap={props.workflowNodesMap ?? createWorkflowNodesMap()}
getVarType={props.getVarType}
environmentVariables={props.environmentVariables}
conversationVariables={props.conversationVariables}
ragVariables={props.ragVariables}
/>
<CaptureEditorPlugin onReady={setEditor} />
</LexicalComposer>,
)
return {
...utils,
getEditor: () => editor,
}
}
describe('HITLInputVariableBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Node guard', () => {
it('should throw when hitl input node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-variable-block-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [],
}}
>
<HITLInputVariableBlockComponent
variables={['node-1', 'output']}
workflowNodesMap={createWorkflowNodesMap()}
/>
</LexicalComposer>,
)
}).toThrow('HITLInputNodePlugin: HITLInputNode not registered on editor')
})
})
describe('Workflow map updates', () => {
it('should update local workflow node map when UPDATE_WORKFLOW_NODES_MAP command is dispatched', async () => {
const { container, getEditor } = renderVariableBlock({
variables: ['node-1', 'output'],
workflowNodesMap: {},
})
expect(screen.queryByText('Node One')).not.toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(true)
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
editor!.update(() => {
$getRoot().selectEnd()
})
handled = editor!.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, createWorkflowNodesMap())
})
expect(handled).toBe(true)
await waitFor(() => {
expect(screen.getByText('Node One')).toBeInTheDocument()
})
})
})
describe('Validation branches', () => {
it('should show invalid state for env variable when environment list does not contain selector', () => {
const { container } = renderVariableBlock({
variables: ['env', 'api_key'],
workflowNodesMap: {},
environmentVariables: [],
})
expect(hasErrorIcon(container)).toBe(true)
})
it('should keep conversation variable valid when selector exists in conversation variables', () => {
const { container } = renderVariableBlock({
variables: ['conversation', 'session_id'],
workflowNodesMap: {},
conversationVariables: [{ variable: 'conversation.session_id', type: 'string' } as Var],
})
expect(hasErrorIcon(container)).toBe(false)
})
it('should keep global system variable valid without workflow node mapping', () => {
const { container } = renderVariableBlock({
variables: ['sys', 'global_name'],
workflowNodesMap: {},
})
expect(screen.getByText('sys.global_name')).toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(false)
})
})
describe('Tooltip payload', () => {
it('should call getVarType with rag selector and use rag node id mapping', () => {
const getVarType = vi.fn(() => Type.number)
const { container } = renderVariableBlock({
variables: ['rag', 'node-rag', 'chunk'],
workflowNodesMap: createWorkflowNodesMap(),
ragVariables: [{ variable: 'rag.node-rag.chunk', type: 'string', isRagVariable: true } as Var],
getVarType,
})
expect(screen.getByText('chunk')).toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(false)
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'rag',
valueSelector: ['rag', 'node-rag', 'chunk'],
})
})
it('should use shortened display name for deep non-rag selectors', () => {
const getVarType = vi.fn(() => Type.string)
renderVariableBlock({
variables: ['node-1', 'parent', 'child'],
workflowNodesMap: createWorkflowNodesMap(),
getVarType,
})
expect(screen.getByText('child')).toBeInTheDocument()
expect(screen.queryByText('parent.child')).not.toBeInTheDocument()
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'node-1',
valueSelector: ['node-1', 'parent', 'child'],
})
})
})
})

View File

@@ -0,0 +1,94 @@
import type { RefObject } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LastRunBlockNode } from '.'
import { CustomTextNode } from '../custom-text/node'
import LastRunBlockComponent from './component'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
const renderComponent = (props?: {
isSelected?: boolean
withNode?: boolean
onParentClick?: () => void
}) => {
const {
isSelected = false,
withNode = true,
onParentClick,
} = props ?? {}
mockUseSelectOrDelete.mockReturnValue(createHookReturn(isSelected))
return render(
<LexicalComposer
initialConfig={{
namespace: 'last-run-block-component-test',
onError: (error: Error) => {
throw error
},
nodes: withNode ? [CustomTextNode, LastRunBlockNode] : [CustomTextNode],
}}
>
<div onClick={onParentClick}>
<LastRunBlockComponent nodeKey="last-run-node" />
</div>
</LexicalComposer>,
)
}
describe('LastRunBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render last run label and apply selected classes when selected', () => {
const { container } = renderComponent({ isSelected: true })
const wrapper = container.querySelector('.group\\/wrap')
expect(screen.getByText('last_run')).toBeInTheDocument()
expect(wrapper).toHaveClass('border-state-accent-solid')
expect(wrapper).toHaveClass('bg-state-accent-hover')
})
it('should apply default classes when not selected', () => {
const { container } = renderComponent({ isSelected: false })
const wrapper = container.querySelector('.group\\/wrap')
expect(wrapper).toHaveClass('border-components-panel-border-subtle')
expect(wrapper).toHaveClass('bg-components-badge-white-to-dark')
})
})
describe('Interactions', () => {
it('should stop click propagation from wrapper', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
renderComponent({ onParentClick })
await user.click(screen.getByText('last_run'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
describe('Node registration guard', () => {
it('should throw when last run node is not registered on editor', () => {
expect(() => {
renderComponent({ withNode: false })
}).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,144 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { LAST_RUN_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_LAST_RUN_COMMAND,
INSERT_LAST_RUN_BLOCK_COMMAND,
LastRunBlock,
LastRunBlockNode,
} from './index'
const renderLastRunBlock = (props?: {
onInsert?: () => void
onDelete?: () => void
}) => {
return renderLexicalEditor({
namespace: 'last-run-block-plugin-test',
nodes: [CustomTextNode, LastRunBlockNode],
children: (
<LastRunBlock {...(props ?? {})} />
),
})
}
describe('LastRunBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert last run block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderLastRunBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(LAST_RUN_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
it('should insert last run block without onInsert callback', async () => {
const { getEditor } = renderLastRunBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(LAST_RUN_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderLastRunBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_LAST_RUN_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderLastRunBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_LAST_RUN_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderLastRunBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_LAST_RUN_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when last run node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'last-run-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<LastRunBlock />
</LexicalComposer>,
)
}).toThrow('Last_RunBlockPlugin: Last_RunBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,92 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { LAST_RUN_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import { LastRunBlockNode } from './index'
import LastRunReplacementBlock from './last-run-block-replacement-block'
const renderReplacementPlugin = (props?: {
onInsert?: () => void
}) => {
return renderLexicalEditor({
namespace: 'last-run-block-replacement-plugin-test',
nodes: [CustomTextNode, LastRunBlockNode],
children: (
<LastRunReplacementBlock {...(props ?? {})} />
),
})
}
describe('LastRunReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text with last run block and call onInsert', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${LAST_RUN_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, LastRunBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder text without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, LAST_RUN_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
})
})
describe('Node registration guard', () => {
it('should throw when last run node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'last-run-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<LastRunReplacementBlock />
</LexicalComposer>,
)
}).toThrow('LastRunMessageBlockNodePlugin: LastRunMessageBlockNode not registered on editor')
})
})
})

View File

@@ -0,0 +1,114 @@
import { act } from '@testing-library/react'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import LastRunBlockComponent from './component'
import {
$createLastRunBlockNode,
$isLastRunBlockNode,
LastRunBlockNode,
} from './node'
const createTestEditor = () => {
return createLexicalTestEditor('last-run-block-node-test', [LastRunBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
let node!: LastRunBlockNode
act(() => {
editor.update(() => {
node = $createLastRunBlockNode()
})
})
return { editor, node }
}
describe('LastRunBlockNode', () => {
describe('Node metadata', () => {
it('should expose last run block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(LastRunBlockNode.getType()).toBe('last-run-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#last_run#}}')
})
it('should clone with the same key', () => {
const { editor, node } = createNodeInEditor()
let cloned!: LastRunBlockNode
act(() => {
editor.update(() => {
cloned = LastRunBlockNode.clone(node)
})
})
expect(cloned).toBeInstanceOf(LastRunBlockNode)
expect(cloned.getKey()).toBe(node.getKey())
expect(cloned).not.toBe(node)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON', () => {
const { editor, node } = createNodeInEditor()
const serialized = node.exportJSON()
let imported!: LastRunBlockNode
act(() => {
editor.update(() => {
imported = LastRunBlockNode.importJSON()
})
})
expect(serialized).toEqual({
type: 'last-run-block',
version: 1,
})
expect(imported).toBeInstanceOf(LastRunBlockNode)
})
it('should decorate with last run block component and node key', () => {
const { node } = createNodeInEditor()
const element = node.decorate()
expect(element.type).toBe(LastRunBlockComponent)
expect(element.props).toEqual({ nodeKey: node.getKey() })
})
})
describe('Helpers', () => {
it('should create last run block node instance from factory', () => {
const { node } = createNodeInEditor()
expect(node).toBeInstanceOf(LastRunBlockNode)
})
it('should identify last run block nodes using type guard helper', () => {
const { node } = createNodeInEditor()
expect($isLastRunBlockNode(node)).toBe(true)
expect($isLastRunBlockNode(null)).toBe(false)
expect($isLastRunBlockNode(undefined)).toBe(false)
})
})
})

View File

@@ -0,0 +1,281 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import OnBlurBlock from './on-blur-or-focus-block'
import { CaptureEditorPlugin } from './test-utils'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
const renderOnBlurBlock = (props?: {
onBlur?: () => void
onFocus?: () => void
}) => {
let editor: LexicalEditor | null = null
const setEditor = (value: LexicalEditor) => {
editor = value
}
const utils = render(
<LexicalComposer
initialConfig={{
namespace: 'on-blur-block-plugin-test',
onError: (error: Error) => {
throw error
},
}}
>
<OnBlurBlock onBlur={props?.onBlur} onFocus={props?.onFocus} />
<CaptureEditorPlugin onReady={setEditor} />
</LexicalComposer>,
)
return {
...utils,
getEditor: () => editor,
}
}
const createBlurEvent = (relatedTarget?: HTMLElement): FocusEvent => {
return new FocusEvent('blur', { relatedTarget: relatedTarget ?? null })
}
const createFocusEvent = (): FocusEvent => {
return new FocusEvent('focus')
}
describe('OnBlurBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Focus and blur handling', () => {
it('should call onFocus when focus command is dispatched', async () => {
const onFocus = vi.fn()
const { getEditor } = renderOnBlurBlock({ onFocus })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
})
expect(handled).toBe(true)
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should call onBlur and dispatch escape after delay when blur target is not var-search-input', async () => {
const onBlur = vi.fn()
const { getEditor } = renderOnBlurBlock({ onBlur })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('button')))
})
expect(handled).toBe(true)
expect(onBlur).toHaveBeenCalledTimes(1)
expect(onEscape).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).toHaveBeenCalledTimes(1)
unregister()
vi.useRealTimers()
})
it('should dispatch delayed escape when onBlur callback is not provided', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
act(() => {
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).toHaveBeenCalledTimes(1)
unregister()
vi.useRealTimers()
})
it('should skip onBlur and delayed escape when blur target is var-search-input', async () => {
const onBlur = vi.fn()
const { getEditor } = renderOnBlurBlock({ onBlur })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const target = document.createElement('input')
target.classList.add('var-search-input')
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(target))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(handled).toBe(true)
expect(onBlur).not.toHaveBeenCalled()
expect(onEscape).not.toHaveBeenCalled()
unregister()
vi.useRealTimers()
})
it('should handle focus command when onFocus callback is not provided', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
})
expect(handled).toBe(true)
})
})
describe('Clear timeout command', () => {
it('should clear scheduled escape timeout when clear command is dispatched', async () => {
const { getEditor } = renderOnBlurBlock({ onBlur: vi.fn() })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
act(() => {
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
act(() => {
editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).not.toHaveBeenCalled()
unregister()
vi.useRealTimers()
})
it('should handle clear command when no timeout is scheduled', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle cleanup', () => {
it('should unregister commands when component unmounts', async () => {
const { getEditor, unmount } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
unmount()
let blurHandled = true
let focusHandled = true
let clearHandled = true
act(() => {
blurHandled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
focusHandled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
clearHandled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
expect(blurHandled).toBe(false)
expect(focusHandled).toBe(false)
expect(clearHandled).toBe(false)
})
})
})

View File

@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react'
import Placeholder from './placeholder'
describe('Placeholder', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render translated default placeholder text when value is not provided', () => {
render(<Placeholder />)
expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument()
})
it('should render provided value instead of translated default text', () => {
render(<Placeholder value={<span>custom placeholder</span>} />)
expect(screen.getByText('custom placeholder')).toBeInTheDocument()
expect(screen.queryByText('common.promptEditor.placeholder')).not.toBeInTheDocument()
})
})
describe('Class names', () => {
it('should apply compact text classes when compact is true', () => {
const { container } = render(<Placeholder compact />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('text-[13px]')
expect(wrapper).toHaveClass('leading-5')
expect(wrapper).not.toHaveClass('leading-6')
})
it('should apply default text classes when compact is false', () => {
const { container } = render(<Placeholder compact={false} />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('text-sm')
expect(wrapper).toHaveClass('leading-6')
expect(wrapper).not.toHaveClass('leading-5')
})
it('should merge additional className when provided', () => {
const { container } = render(<Placeholder className="custom-class" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('custom-class')
})
})
})

View File

@@ -0,0 +1,51 @@
import type { RefObject } from 'react'
import { render, screen } from '@testing-library/react'
import QueryBlockComponent from './component'
import { DELETE_QUERY_BLOCK_COMMAND } from './index'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
describe('QueryBlockComponent', () => {
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render query title and register select or delete hook with node key', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
render(<QueryBlockComponent nodeKey="query-node-1" />)
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('query-node-1', DELETE_QUERY_BLOCK_COMMAND)
expect(screen.getByText('common.promptEditor.query.item.title')).toBeInTheDocument()
})
it('should apply selected border class when the block is selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(true))
const { container } = render(<QueryBlockComponent nodeKey="query-node-2" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('!border-[#FD853A]')
})
it('should not apply selected border class when the block is not selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
const { container } = render(<QueryBlockComponent nodeKey="query-node-3" />)
const wrapper = container.firstElementChild
expect(wrapper).not.toHaveClass('!border-[#FD853A]')
})
})
})

View File

@@ -0,0 +1,144 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { QUERY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_QUERY_BLOCK_COMMAND,
INSERT_QUERY_BLOCK_COMMAND,
QueryBlock,
QueryBlockNode,
} from './index'
const renderQueryBlock = (props: {
onInsert?: () => void
onDelete?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'query-block-plugin-test',
nodes: [CustomTextNode, QueryBlockNode],
children: (
<QueryBlock {...props} />
),
})
}
describe('QueryBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert query block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderQueryBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(QUERY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
it('should insert query block without onInsert callback', async () => {
const { getEditor } = renderQueryBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(QUERY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderQueryBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderQueryBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderQueryBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_QUERY_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when query node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'query-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<QueryBlock />
</LexicalComposer>,
)
}).toThrow('QueryBlockPlugin: QueryBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,113 @@
import { act } from '@testing-library/react'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import QueryBlockComponent from './component'
import {
$createQueryBlockNode,
$isQueryBlockNode,
QueryBlockNode,
} from './node'
describe('QueryBlockNode', () => {
const createTestEditor = () => {
return createLexicalTestEditor('query-block-node-test', [QueryBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
let node!: QueryBlockNode
act(() => {
editor.update(() => {
node = $createQueryBlockNode()
})
})
return { editor, node }
}
describe('Node metadata', () => {
it('should expose query block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(QueryBlockNode.getType()).toBe('query-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#query#}}')
})
it('should clone into a new query block node', () => {
const { editor, node } = createNodeInEditor()
let cloned!: QueryBlockNode
act(() => {
editor.update(() => {
cloned = QueryBlockNode.clone()
})
})
expect(cloned).toBeInstanceOf(QueryBlockNode)
expect(cloned).not.toBe(node)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON', () => {
const { editor, node } = createNodeInEditor()
const serialized = node.exportJSON()
let imported!: QueryBlockNode
act(() => {
editor.update(() => {
imported = QueryBlockNode.importJSON()
})
})
expect(serialized).toEqual({
type: 'query-block',
version: 1,
})
expect(imported).toBeInstanceOf(QueryBlockNode)
})
it('should decorate with query block component and node key', () => {
const { node } = createNodeInEditor()
const element = node.decorate()
expect(element.type).toBe(QueryBlockComponent)
expect(element.props).toEqual({ nodeKey: node.getKey() })
})
})
describe('Helpers', () => {
it('should create query block node instance from factory', () => {
const { node } = createNodeInEditor()
expect(node).toBeInstanceOf(QueryBlockNode)
})
it('should identify query block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isQueryBlockNode(node)).toBe(true)
expect($isQueryBlockNode(null)).toBe(false)
expect($isQueryBlockNode(undefined)).toBe(false)
})
})
})

View File

@@ -0,0 +1,92 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { QUERY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import { QueryBlockNode } from './index'
import QueryBlockReplacementBlock from './query-block-replacement-block'
const renderReplacementPlugin = (props: {
onInsert?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'query-block-replacement-plugin-test',
nodes: [CustomTextNode, QueryBlockNode],
children: (
<QueryBlockReplacementBlock {...props} />
),
})
}
describe('QueryBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text with query block and call onInsert', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${QUERY_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, QueryBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder text without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, QUERY_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
})
})
describe('Node registration guard', () => {
it('should throw when query node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'query-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<QueryBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('QueryBlockNodePlugin: QueryBlockNode not registered on editor')
})
})
})

View File

@@ -0,0 +1,53 @@
import type { RefObject } from 'react'
import { render, screen } from '@testing-library/react'
import RequestURLBlockComponent from './component'
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
describe('RequestURLBlockComponent', () => {
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render request URL title and register select or delete hook with node key', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
render(<RequestURLBlockComponent nodeKey="node-1" />)
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('node-1', DELETE_REQUEST_URL_BLOCK_COMMAND)
expect(screen.getByText('common.promptEditor.requestURL.item.title')).toBeInTheDocument()
})
it('should apply selected border classes when the block is selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(true))
const { container } = render(<RequestURLBlockComponent nodeKey="node-2" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('!border-[#7839ee]')
expect(wrapper).toHaveClass('hover:!border-[#7839ee]')
})
it('should not apply selected border classes when the block is not selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
const { container } = render(<RequestURLBlockComponent nodeKey="node-3" />)
const wrapper = container.firstElementChild
expect(wrapper).not.toHaveClass('!border-[#7839ee]')
expect(wrapper).not.toHaveClass('hover:!border-[#7839ee]')
})
})
})

View File

@@ -0,0 +1,144 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_REQUEST_URL_BLOCK_COMMAND,
INSERT_REQUEST_URL_BLOCK_COMMAND,
RequestURLBlock,
RequestURLBlockNode,
} from './index'
const renderRequestURLBlock = (props: {
onInsert?: () => void
onDelete?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'request-url-block-plugin-test',
nodes: [CustomTextNode, RequestURLBlockNode],
children: (
<RequestURLBlock {...props} />
),
})
}
describe('RequestURLBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert request URL block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderRequestURLBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(REQUEST_URL_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
it('should insert request URL block without onInsert callback', async () => {
const { getEditor } = renderRequestURLBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(REQUEST_URL_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderRequestURLBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderRequestURLBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderRequestURLBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when request URL node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'request-url-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<RequestURLBlock />
</LexicalComposer>,
)
}).toThrow('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
})
})
})

View File

@@ -0,0 +1,114 @@
import { act } from '@testing-library/react'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import RequestURLBlockComponent from './component'
import {
$createRequestURLBlockNode,
$isRequestURLBlockNode,
RequestURLBlockNode,
} from './node'
describe('RequestURLBlockNode', () => {
const createTestEditor = () => {
return createLexicalTestEditor('request-url-block-node-test', [RequestURLBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
let node!: RequestURLBlockNode
act(() => {
editor.update(() => {
node = $createRequestURLBlockNode()
})
})
return { editor, node }
}
describe('Node metadata', () => {
it('should expose request URL block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(RequestURLBlockNode.getType()).toBe('request-url-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#url#}}')
})
it('should clone with the same key', () => {
const { editor, node } = createNodeInEditor()
let cloned!: RequestURLBlockNode
act(() => {
editor.update(() => {
cloned = RequestURLBlockNode.clone(node)
})
})
expect(cloned).toBeInstanceOf(RequestURLBlockNode)
expect(cloned.getKey()).toBe(node.getKey())
expect(cloned).not.toBe(node)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON', () => {
const { editor, node } = createNodeInEditor()
const serialized = node.exportJSON()
let imported!: RequestURLBlockNode
act(() => {
editor.update(() => {
imported = RequestURLBlockNode.importJSON()
})
})
expect(serialized).toEqual({
type: 'request-url-block',
version: 1,
})
expect(imported).toBeInstanceOf(RequestURLBlockNode)
})
it('should decorate with request URL block component and node key', () => {
const { node } = createNodeInEditor()
const element = node.decorate()
expect(element.type).toBe(RequestURLBlockComponent)
expect(element.props).toEqual({ nodeKey: node.getKey() })
})
})
describe('Helpers', () => {
it('should create request URL block node instance from factory', () => {
const { node } = createNodeInEditor()
expect(node).toBeInstanceOf(RequestURLBlockNode)
})
it('should identify request URL block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isRequestURLBlockNode(node)).toBe(true)
expect($isRequestURLBlockNode(null)).toBe(false)
expect($isRequestURLBlockNode(undefined)).toBe(false)
})
})
})

View File

@@ -0,0 +1,92 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import { RequestURLBlockNode } from './index'
import RequestURLBlockReplacementBlock from './request-url-block-replacement-block'
const renderReplacementPlugin = (props: {
onInsert?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'request-url-block-replacement-plugin-test',
nodes: [CustomTextNode, RequestURLBlockNode],
children: (
<RequestURLBlockReplacementBlock {...props} />
),
})
}
describe('RequestURLBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text with request URL block and call onInsert', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${REQUEST_URL_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder text without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, REQUEST_URL_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
})
})
describe('Node registration guard', () => {
it('should throw when request URL node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'request-url-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<RequestURLBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
})
})
})

View File

@@ -0,0 +1,162 @@
import type {
Klass,
LexicalEditor,
LexicalNode,
} from 'lexical'
import type { ReactNode } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import {
$createParagraphNode,
$getRoot,
$nodesOfType,
createEditor,
} from 'lexical'
import { createElement } from 'react'
import { expect } from 'vitest'
import { CaptureEditorPlugin } from './test-utils'
type RenderLexicalEditorProps = {
namespace: string
nodes?: Array<Klass<LexicalNode>>
children: ReactNode
}
type RenderLexicalEditorResult = ReturnType<typeof render> & {
getEditor: () => LexicalEditor | null
}
export const renderLexicalEditor = ({
namespace,
nodes = [],
children,
}: RenderLexicalEditorProps): RenderLexicalEditorResult => {
let editor: LexicalEditor | null = null
const utils = render(createElement(
LexicalComposer,
{
initialConfig: {
namespace,
onError: (error: Error) => {
throw error
},
nodes,
},
},
children,
createElement(CaptureEditorPlugin, {
onReady: (value) => {
editor = value
},
}),
))
return {
...utils,
getEditor: () => editor,
}
}
export const waitForEditorReady = async (getEditor: () => LexicalEditor | null): Promise<LexicalEditor> => {
await waitFor(() => {
if (!getEditor())
throw new Error('Editor is not ready yet')
})
const editor = getEditor()
if (!editor)
throw new Error('Editor is not available')
return editor
}
export const selectRootEnd = (editor: LexicalEditor) => {
act(() => {
editor.update(() => {
$getRoot().selectEnd()
})
})
}
export const readRootTextContent = (editor: LexicalEditor): string => {
let content = ''
editor.getEditorState().read(() => {
content = $getRoot().getTextContent()
})
return content
}
export const getNodeCount = <T extends LexicalNode>(editor: LexicalEditor, nodeType: Klass<T>): number => {
let count = 0
editor.getEditorState().read(() => {
count = $nodesOfType(nodeType).length
})
return count
}
export const getNodesByType = <T extends LexicalNode>(editor: LexicalEditor, nodeType: Klass<T>): T[] => {
let nodes: T[] = []
editor.getEditorState().read(() => {
nodes = $nodesOfType(nodeType)
})
return nodes
}
export const readEditorStateValue = <T>(editor: LexicalEditor, reader: () => T): T => {
let value: T | undefined
editor.getEditorState().read(() => {
value = reader()
})
if (value === undefined)
throw new Error('Failed to read editor state value')
return value
}
export const setEditorRootText = (
editor: LexicalEditor,
text: string,
createTextNode: (text: string) => LexicalNode,
) => {
act(() => {
editor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append(createTextNode(text))
root.append(paragraph)
paragraph.selectEnd()
})
})
}
export const createLexicalTestEditor = (namespace: string, nodes: Array<Klass<LexicalNode>>) => {
return createEditor({
namespace,
onError: (error: Error) => {
throw error
},
nodes,
})
}
export const expectInlineWrapperDom = (dom: HTMLElement, extraClasses: string[] = []) => {
expect(dom.tagName).toBe('DIV')
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('align-middle')
extraClasses.forEach((className) => {
expect(dom).toHaveClass(className)
})
}

View File

@@ -0,0 +1,17 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
type CaptureEditorPluginProps = {
onReady: (editor: LexicalEditor) => void
}
export const CaptureEditorPlugin = ({ onReady }: CaptureEditorPluginProps) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
onReady(editor)
}, [editor, onReady])
return null
}

View File

@@ -0,0 +1,58 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, screen, waitFor } from '@testing-library/react'
import { CaptureEditorPlugin } from './test-utils'
import TreeViewPlugin from './tree-view'
const { mockTreeView } = vi.hoisted(() => ({
mockTreeView: vi.fn(),
}))
vi.mock('@lexical/react/LexicalTreeView', () => ({
TreeView: (props: unknown) => {
mockTreeView(props)
return <div data-testid="lexical-tree-view" />
},
}))
describe('TreeViewPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render lexical tree view with expected classes and current editor', async () => {
let editor: LexicalEditor | null = null
render(
<LexicalComposer
initialConfig={{
namespace: 'tree-view-plugin-test',
onError: (error: Error) => {
throw error
},
}}
>
<TreeViewPlugin />
<CaptureEditorPlugin onReady={(value) => {
editor = value
}}
/>
</LexicalComposer>,
)
await waitFor(() => {
expect(editor).not.toBeNull()
})
expect(screen.getByTestId('lexical-tree-view')).toBeInTheDocument()
const firstCallProps = mockTreeView.mock.calls[0][0] as Record<string, unknown>
expect(firstCallProps.editor).toBe(editor)
expect(firstCallProps.viewClassName).toBe('tree-view-output')
expect(firstCallProps.treeTypeButtonClassName).toBe('debug-treetype-button')
expect(firstCallProps.timeTravelPanelClassName).toBe('debug-timetravel-panel')
expect(firstCallProps.timeTravelButtonClassName).toBe('debug-timetravel-button')
expect(firstCallProps.timeTravelPanelSliderClassName).toBe('debug-timetravel-panel-slider')
expect(firstCallProps.timeTravelPanelButtonClassName).toBe('debug-timetravel-panel-button')
})
})

View File

@@ -0,0 +1,212 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $getRoot, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { CustomTextNode } from './custom-text/node'
import { CaptureEditorPlugin } from './test-utils'
import UpdateBlock, {
PROMPT_EDITOR_INSERT_QUICKLY,
PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
} from './update-block'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
const { mockUseEventEmitterContextContext } = vi.hoisted(() => ({
mockUseEventEmitterContextContext: vi.fn(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
type TestEvent = {
type: string
instanceId?: string
payload?: string
}
const readEditorText = (editor: LexicalEditor) => {
let content = ''
editor.getEditorState().read(() => {
content = $getRoot().getTextContent()
})
return content
}
const selectRootEnd = (editor: LexicalEditor) => {
act(() => {
editor.update(() => {
$getRoot().selectEnd()
})
})
}
const setup = (props?: {
instanceId?: string
withEventEmitter?: boolean
}) => {
const callbacks: Array<(event: TestEvent) => void> = []
const eventEmitter = props?.withEventEmitter === false
? null
: {
useSubscription: vi.fn((callback: (event: TestEvent) => void) => {
callbacks.push(callback)
}),
}
mockUseEventEmitterContextContext.mockReturnValue({ eventEmitter })
let editor: LexicalEditor | null = null
const onReady = (value: LexicalEditor) => {
editor = value
}
render(
<LexicalComposer
initialConfig={{
namespace: 'update-block-plugin-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<UpdateBlock instanceId={props?.instanceId} />
<CaptureEditorPlugin onReady={onReady} />
</LexicalComposer>,
)
const emit = (event: TestEvent) => {
act(() => {
callbacks.forEach(callback => callback(event))
})
}
return {
callbacks,
emit,
eventEmitter,
getEditor: () => editor,
}
}
describe('UpdateBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Subscription setup', () => {
it('should register two subscriptions when event emitter is available', () => {
const { callbacks, eventEmitter } = setup({ instanceId: 'instance-1' })
expect(eventEmitter).not.toBeNull()
expect(eventEmitter?.useSubscription).toHaveBeenCalledTimes(2)
expect(callbacks).toHaveLength(2)
})
it('should render without subscriptions when event emitter is null', () => {
const { callbacks, eventEmitter } = setup({ withEventEmitter: false })
expect(eventEmitter).toBeNull()
expect(callbacks).toHaveLength(0)
})
})
describe('Update value event', () => {
it('should update editor state when update event matches instance id', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'instance-1',
payload: 'updated text',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('updated text')
})
})
it('should ignore update event when instance id does not match', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'instance-2',
payload: 'should not apply',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('')
})
})
})
describe('Quick insert event', () => {
it('should insert slash and dispatch clear command when quick insert event matches instance id', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
selectRootEnd(editor!)
const clearCommandHandler = vi.fn(() => true)
const unregister = editor!.registerCommand(
CLEAR_HIDE_MENU_TIMEOUT,
clearCommandHandler,
COMMAND_PRIORITY_EDITOR,
)
emit({
type: PROMPT_EDITOR_INSERT_QUICKLY,
instanceId: 'instance-1',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('/')
})
expect(clearCommandHandler).toHaveBeenCalledTimes(1)
unregister()
})
it('should ignore quick insert event when instance id does not match', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
selectRootEnd(editor!)
emit({
type: PROMPT_EDITOR_INSERT_QUICKLY,
instanceId: 'instance-2',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('')
})
})
})
})

View File

@@ -0,0 +1,89 @@
import { act, waitFor } from '@testing-library/react'
import { CustomTextNode } from '../custom-text/node'
import {
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import VariableBlock, {
INSERT_VARIABLE_BLOCK_COMMAND,
INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
} from './index'
const renderVariableBlock = () => {
return renderLexicalEditor({
namespace: 'variable-block-plugin-test',
nodes: [CustomTextNode],
children: (
<VariableBlock />
),
})
}
describe('VariableBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert an opening brace when INSERT_VARIABLE_BLOCK_COMMAND is dispatched', async () => {
const { getEditor } = renderVariableBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_VARIABLE_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe('{')
})
})
it('should insert provided value when INSERT_VARIABLE_VALUE_BLOCK_COMMAND is dispatched', async () => {
const { getEditor } = renderVariableBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, 'user.name')
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe('user.name')
})
})
})
describe('Lifecycle cleanup', () => {
it('should unregister command handlers when the plugin unmounts', async () => {
const { getEditor, unmount } = renderVariableBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let variableHandled = true
let valueHandled = true
act(() => {
variableHandled = editor.dispatchCommand(INSERT_VARIABLE_BLOCK_COMMAND, undefined)
valueHandled = editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, 'ignored')
})
expect(variableHandled).toBe(false)
expect(valueHandled).toBe(false)
})
})
})

View File

@@ -0,0 +1,87 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { EntityMatch } from '@lexical/text'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render } from '@testing-library/react'
import { useLexicalTextEntity } from '../../hooks'
import VariableValueBlock from './index'
import { $createVariableValueBlockNode, VariableValueBlockNode } from './node'
vi.mock('../../hooks')
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('VariableValueBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
vi.mocked($createVariableValueBlockNode).mockImplementation(
text => ({ createdText: text } as unknown as VariableValueBlockNode),
)
})
it('should render null and register lexical text entity when node is registered', () => {
const { container } = renderWithLexicalContext(<VariableValueBlock />)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([VariableValueBlockNode])
expect(useLexicalTextEntity).toHaveBeenCalledWith(
expect.any(Function),
VariableValueBlockNode,
expect.any(Function),
)
})
it('should throw when VariableValueBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<VariableValueBlock />)).toThrow(
'VariableValueBlockPlugin: VariableValueNode not registered on editor',
)
})
it('should return match offsets when placeholder exists and null when not present', () => {
renderWithLexicalContext(<VariableValueBlock />)
const getMatch = vi.mocked(useLexicalTextEntity).mock.calls[0][0] as (text: string) => EntityMatch | null
const match = getMatch('prefix {{foo_1}} suffix')
expect(match).toEqual({ start: 7, end: 16 })
expect(getMatch('prefix without variable')).toBeNull()
})
it('should create variable node from text node content in create callback', () => {
renderWithLexicalContext(<VariableValueBlock />)
const createNode = vi.mocked(useLexicalTextEntity).mock.calls[0][2] as (
textNode: { getTextContent: () => string },
) => VariableValueBlockNode
const created = createNode({
getTextContent: () => '{{account_id}}',
})
expect($createVariableValueBlockNode).toHaveBeenCalledWith('{{account_id}}')
expect(created).toEqual({ createdText: '{{account_id}}' })
})
})

View File

@@ -0,0 +1,92 @@
import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNode } from 'lexical'
import { createEditor } from 'lexical'
import {
$createVariableValueBlockNode,
$isVariableValueNodeBlock,
VariableValueBlockNode,
} from './node'
describe('VariableValueBlockNode', () => {
let editor: LexicalEditor
let config: EditorConfig
beforeEach(() => {
vi.clearAllMocks()
editor = createEditor({
nodes: [VariableValueBlockNode as unknown as Klass<LexicalNode>],
})
config = editor._config
})
const runInEditor = (callback: () => void) => {
editor.update(callback, { discrete: true })
}
it('should expose static type and clone with same text/key', () => {
runInEditor(() => {
const original = new VariableValueBlockNode('value-text', 'node-key')
const cloned = VariableValueBlockNode.clone(original)
expect(VariableValueBlockNode.getType()).toBe('variable-value-block')
expect(cloned).toBeInstanceOf(VariableValueBlockNode)
expect(cloned).not.toBe(original)
expect(cloned.getKey()).toBe('node-key')
})
})
it('should add block classes in createDOM and disallow text insertion before', () => {
runInEditor(() => {
const node = new VariableValueBlockNode('hello')
const dom = node.createDOM(config)
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('px-0.5')
expect(dom).toHaveClass('h-[22px]')
expect(dom).toHaveClass('text-text-accent')
expect(dom).toHaveClass('rounded-[5px]')
expect(dom).toHaveClass('align-middle')
expect(node.canInsertTextBefore()).toBe(false)
})
})
it('should import serialized node and preserve text metadata in export', () => {
runInEditor(() => {
const serialized = {
detail: 2,
format: 1,
mode: 'token',
style: 'color:red;',
text: '{{profile_name}}',
type: 'text',
version: 1,
} as SerializedTextNode
const imported = VariableValueBlockNode.importJSON(serialized)
const exported = imported.exportJSON()
expect(exported).toEqual({
detail: 2,
format: 1,
mode: 'token',
style: 'color:red;',
text: '{{profile_name}}',
type: 'variable-value-block',
version: 1,
})
})
})
it('should create node with helper and support type guard checks', () => {
runInEditor(() => {
const node = $createVariableValueBlockNode('{{org_id}}')
expect(node).toBeInstanceOf(VariableValueBlockNode)
expect(node.getTextContent()).toBe('{{org_id}}')
expect($isVariableValueNodeBlock(node)).toBe(true)
expect($isVariableValueNodeBlock(null)).toBe(false)
expect($isVariableValueNodeBlock(undefined)).toBe(false)
expect($isVariableValueNodeBlock({} as LexicalNode)).toBe(false)
})
})
})

View File

@@ -0,0 +1,507 @@
import type { LexicalEditor } from 'lexical'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useReactFlow, useStoreApi } from 'reactflow'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { useSelectOrDelete } from '../../hooks'
import WorkflowVariableBlockComponent from './component'
import { UPDATE_WORKFLOW_NODES_MAP } from './index'
import { WorkflowVariableBlockNode } from './node'
const { mockVarLabel, mockIsExceptionVariable, mockForcedVariableKind } = vi.hoisted(() => ({
mockVarLabel: vi.fn(),
mockIsExceptionVariable: vi.fn<(variable: string, nodeType?: BlockEnum) => boolean>(() => false),
mockForcedVariableKind: { value: '' as '' | 'env' | 'conversation' | 'rag' },
}))
vi.mock('@lexical/react/LexicalComposerContext')
vi.mock('@lexical/utils')
vi.mock('reactflow')
vi.mock('../../hooks')
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
isExceptionVariable: mockIsExceptionVariable,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>()
return {
...actual,
isENV: (valueSelector: ValueSelector) => {
if (mockForcedVariableKind.value === 'env')
return true
return actual.isENV(valueSelector)
},
isConversationVar: (valueSelector: ValueSelector) => {
if (mockForcedVariableKind.value === 'conversation')
return true
return actual.isConversationVar(valueSelector)
},
isRagVariableVar: (valueSelector: ValueSelector) => {
if (mockForcedVariableKind.value === 'rag')
return true
return actual.isRagVariableVar(valueSelector)
},
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInEditor: (props: {
onClick: (e: React.MouseEvent) => void
errorMsg?: string
nodeTitle?: string
nodeType?: BlockEnum
notShowFullPath?: boolean
}) => {
mockVarLabel(props)
return (
<button type="button" onClick={props.onClick}>
label
</button>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel', () => ({
default: (props: {
nodeName: string
path: string[]
varType: Type
nodeType?: BlockEnum
}) => <div data-testid="var-full-path-panel">{props.nodeName}</div>,
}))
const mockRegisterCommand = vi.fn()
const mockHasNodes = vi.fn()
const mockSetViewport = vi.fn()
const mockGetState = vi.fn()
const mockEditor = {
registerCommand: mockRegisterCommand,
hasNodes: mockHasNodes,
} as unknown as LexicalEditor
describe('WorkflowVariableBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockForcedVariableKind.value = ''
mockHasNodes.mockReturnValue(true)
mockRegisterCommand.mockReturnValue(vi.fn())
mockGetState.mockReturnValue({ transform: [0, 0, 2] })
vi.mocked(useLexicalComposerContext).mockReturnValue([
mockEditor,
{},
] as unknown as ReturnType<typeof useLexicalComposerContext>)
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
vi.mocked(useSelectOrDelete).mockReturnValue([{ current: null }, false])
vi.mocked(useReactFlow).mockReturnValue({
setViewport: mockSetViewport,
} as unknown as ReturnType<typeof useReactFlow>)
vi.mocked(useStoreApi).mockReturnValue({
getState: mockGetState,
} as unknown as ReturnType<typeof useStoreApi>)
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'output']}
workflowNodesMap={{}}
/>,
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
it('should render variable label and register update command', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'output']}
workflowNodesMap={{}}
/>,
)
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
expect(mockRegisterCommand).toHaveBeenCalledWith(
UPDATE_WORKFLOW_NODES_MAP,
expect.any(Function),
expect.any(Number),
)
})
it('should call setViewport when label is clicked and node exists', async () => {
const user = userEvent.setup()
const workflowContainer = document.createElement('div')
workflowContainer.id = 'workflow-container'
Object.defineProperty(workflowContainer, 'clientWidth', { value: 1000, configurable: true })
Object.defineProperty(workflowContainer, 'clientHeight', { value: 800, configurable: true })
document.body.appendChild(workflowContainer)
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'group', 'field']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 50, y: 80 },
},
}}
/>,
)
await user.click(screen.getByRole('button', { name: 'label' }))
expect(mockSetViewport).toHaveBeenCalledWith({
x: (1000 - 400 - 200 * 2) / 2 - 50 * 2,
y: (800 - 100 * 2) / 2 - 80 * 2,
zoom: 2,
})
})
it('should render safely when node exists and getVarType is not provided', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'group', 'field']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 0, y: 0 },
},
}}
/>,
)
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
})
it('should pass computed varType when getVarType is provided', () => {
const getVarType = vi.fn(() => Type.number)
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'group', 'field']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 0, y: 0 },
},
}}
getVarType={getVarType}
/>,
)
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'node-1',
valueSelector: ['node-1', 'group', 'field'] as ValueSelector,
})
})
it('should mark env variable invalid when not found in environmentVariables', () => {
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env', 'missing_key']}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should keep env variable valid when environmentVariables is omitted', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env', 'missing_key']}
workflowNodesMap={{}}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should treat env variable as valid when it exists in environmentVariables', () => {
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env', 'valid_key']}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should handle env selector with missing segment when environmentVariables are provided', () => {
const environmentVariables: Var[] = [{ variable: 'env.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env']}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should evaluate env fallback selector tokens when classifier is forced', () => {
mockForcedVariableKind.value = 'env'
const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={[]}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should treat conversation variable as valid when found in conversationVariables', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation', 'topic']}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should keep conversation variable valid when conversationVariables is omitted', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation', 'topic']}
workflowNodesMap={{}}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should mark conversation variable invalid when not found in conversationVariables', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.other', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation', 'topic']}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should handle conversation selector with missing segment when conversationVariables are provided', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation']}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should evaluate conversation fallback selector tokens when classifier is forced', () => {
mockForcedVariableKind.value = 'conversation'
const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={[]}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should treat global variable as valid without node', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['sys', 'user_id']}
workflowNodesMap={{}}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should use rag variable validation path', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared', 'answer']}
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should keep rag variable valid when ragVariables is omitted', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared', 'answer']}
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should mark rag variable invalid when not found in ragVariables', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.other', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared', 'answer']}
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should handle rag selector with missing segment when ragVariables are provided', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared']}
workflowNodesMap={{ shared: { title: 'Rag', type: BlockEnum.Tool } as never }}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should evaluate rag fallback selector tokens when classifier is forced', () => {
mockForcedVariableKind.value = 'rag'
const ragVariables: Var[] = [{ variable: '..', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={[]}
workflowNodesMap={{}}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should apply workflow node map updates through command handler', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'field']}
workflowNodesMap={{}}
/>,
)
const updateHandler = mockRegisterCommand.mock.calls[0][1] as (map: Record<string, unknown>) => boolean
let result = false
act(() => {
result = updateHandler({
'node-1': {
title: 'Updated',
type: BlockEnum.LLM,
width: 100,
height: 50,
position: { x: 0, y: 0 },
},
})
})
expect(result).toBe(true)
})
})

View File

@@ -0,0 +1,204 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import type { WorkflowNodesMap } from './node'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
CLEAR_HIDE_MENU_TIMEOUT,
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP,
WorkflowVariableBlock,
WorkflowVariableBlockNode,
} from './index'
import { $createWorkflowVariableBlockNode } from './node'
vi.mock('@lexical/utils')
vi.mock('lexical', async () => {
const actual = await vi.importActual('lexical')
return {
...actual,
$insertNodes: vi.fn(),
createCommand: vi.fn(name => name),
COMMAND_PRIORITY_EDITOR: 1,
}
})
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterCommand = vi.fn()
const mockDispatchCommand = vi.fn()
const mockUpdate = vi.fn((callback: () => void) => callback())
const mockEditor = {
hasNodes: mockHasNodes,
registerCommand: mockRegisterCommand,
dispatchCommand: mockDispatchCommand,
update: mockUpdate,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('WorkflowVariableBlock', () => {
const workflowNodesMap: WorkflowNodesMap = {
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 10, y: 20 },
},
}
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterCommand.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ id: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
})
it('should render null and register insert/delete commands', () => {
const { container } = renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
1,
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
2,
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(WorkflowVariableBlock.displayName).toBe('WorkflowVariableBlock')
})
it('should dispatch workflow node map update on mount', () => {
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
expect(mockUpdate).toHaveBeenCalled()
expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
it('should insert workflow variable block node and call onInsert', () => {
const onInsert = vi.fn()
const getVarType = vi.fn(() => Type.string)
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
onInsert={onInsert}
getVarType={getVarType}
/>,
)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
const result = insertHandler(['node-1', 'answer'])
expect(mockDispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined)
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'answer'],
workflowNodesMap,
getVarType,
)
expect($insertNodes).toHaveBeenCalledWith([{ id: 'workflow-node' }])
expect(onInsert).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on insert when onInsert is omitted', () => {
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
expect(insertHandler(['node-1', 'answer'])).toBe(true)
})
it('should call onDelete and return true when delete handler runs', () => {
const onDelete = vi.fn()
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
onDelete={onDelete}
/>,
)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
const result = deleteHandler()
expect(onDelete).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on delete when onDelete is omitted', () => {
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
expect(deleteHandler()).toBe(true)
})
it('should run merged cleanup on unmount', () => {
const insertCleanup = vi.fn()
const deleteCleanup = vi.fn()
mockRegisterCommand
.mockReturnValueOnce(insertCleanup)
.mockReturnValueOnce(deleteCleanup)
const { unmount } = renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
unmount()
expect(insertCleanup).toHaveBeenCalledTimes(1)
expect(deleteCleanup).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,166 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import type { Var } from '@/app/components/workflow/types'
import { createEditor } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
$createWorkflowVariableBlockNode,
$isWorkflowVariableBlockNode,
WorkflowVariableBlockNode,
} from './node'
describe('WorkflowVariableBlockNode', () => {
let editor: LexicalEditor
beforeEach(() => {
vi.clearAllMocks()
editor = createEditor({
nodes: [WorkflowVariableBlockNode as unknown as Klass<LexicalNode>],
})
})
const runInEditor = (callback: () => void) => {
editor.update(callback, { discrete: true })
}
it('should expose type and clone with same payload', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const original = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
'node-key',
)
const cloned = WorkflowVariableBlockNode.clone(original)
expect(WorkflowVariableBlockNode.getType()).toBe('workflow-variable-block')
expect(cloned).toBeInstanceOf(WorkflowVariableBlockNode)
expect(cloned.getKey()).toBe(original.getKey())
})
})
it('should be inline and create expected dom classes', () => {
runInEditor(() => {
const node = new WorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
const dom = node.createDOM()
expect(node.isInline()).toBe(true)
expect(dom.tagName).toBe('DIV')
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('align-middle')
expect(node.updateDOM()).toBe(false)
})
})
it('should decorate with component props from node state', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.number)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
'decorator-key',
environmentVariables,
conversationVariables,
ragVariables,
)
const decorated = node.decorate()
expect(decorated.props.nodeKey).toBe('decorator-key')
expect(decorated.props.variables).toEqual(['node-1', 'answer'])
expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
expect(decorated.props.environmentVariables).toEqual(environmentVariables)
expect(decorated.props.conversationVariables).toEqual(conversationVariables)
expect(decorated.props.ragVariables).toEqual(ragVariables)
})
})
it('should export and import json with full payload', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
undefined,
environmentVariables,
conversationVariables,
ragVariables,
)
expect(node.exportJSON()).toEqual({
type: 'workflow-variable-block',
version: 1,
variables: ['node-1', 'answer'],
workflowNodesMap: { 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
})
const imported = WorkflowVariableBlockNode.importJSON({
type: 'workflow-variable-block',
version: 1,
variables: ['node-2', 'result'],
workflowNodesMap: { 'node-2': { title: 'B', type: BlockEnum.Tool } },
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
})
expect(imported).toBeInstanceOf(WorkflowVariableBlockNode)
expect(imported.getVariables()).toEqual(['node-2', 'result'])
expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } })
})
})
it('should return getters and text content in expected format', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
undefined,
environmentVariables,
conversationVariables,
ragVariables,
)
expect(node.getVariables()).toEqual(['node-1', 'answer'])
expect(node.getWorkflowNodesMap()).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
expect(node.getVarType()).toBe(getVarType)
expect(node.getEnvironmentVariables()).toEqual(environmentVariables)
expect(node.getConversationVariables()).toEqual(conversationVariables)
expect(node.getRagVariables()).toEqual(ragVariables)
expect(node.getTextContent()).toBe('{{#node-1.answer#}}')
})
})
it('should create node helper and type guard checks', () => {
runInEditor(() => {
const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
expect($isWorkflowVariableBlockNode(node)).toBe(true)
expect($isWorkflowVariableBlockNode(null)).toBe(false)
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)
expect($isWorkflowVariableBlockNode({} as LexicalNode)).toBe(false)
})
})
})

View File

@@ -0,0 +1,221 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { EntityMatch } from '@lexical/text'
import type { LexicalEditor, LexicalNode } from 'lexical'
import type { ReactElement } from 'react'
import type { WorkflowNodesMap } from './node'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $applyNodeReplacement } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import { WorkflowVariableBlockNode } from './index'
import { $createWorkflowVariableBlockNode } from './node'
import WorkflowVariableBlockReplacementBlock from './workflow-variable-block-replacement-block'
vi.mock('@lexical/utils')
vi.mock('lexical')
vi.mock('../../utils')
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterNodeTransform = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
registerNodeTransform: mockRegisterNodeTransform,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('WorkflowVariableBlockReplacementBlock', () => {
const variables: NodeOutPutVar[] = [
{
nodeId: 'env',
title: 'ENV',
vars: [{ variable: 'env.key', type: VarType.string }],
},
{
nodeId: 'conversation',
title: 'Conversation',
vars: [{ variable: 'conversation.topic', type: VarType.string }],
},
{
nodeId: 'node-1',
title: 'Node A',
vars: [
{ variable: 'output', type: VarType.string },
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
],
},
{
nodeId: 'rag',
title: 'RAG',
vars: [{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true }],
},
]
const workflowNodesMap: WorkflowNodesMap = {
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 20, y: 40 },
},
}
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterNodeTransform.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ type: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
})
it('should register transform and cleanup on unmount', () => {
const transformCleanup = vi.fn()
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
const { unmount, container } = renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
unmount()
expect(transformCleanup).toHaveBeenCalledTimes(1)
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)).toThrow('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor')
})
it('should pass matcher and creator to decoratorTransform', () => {
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
const textNode = { id: 'text-node' } as unknown as LexicalNode
transformCallback(textNode)
expect(decoratorTransform).toHaveBeenCalledWith(
textNode,
expect.any(Function),
expect.any(Function),
)
})
it('should match variable placeholders and return null for non-placeholder text', () => {
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
const match = getMatch('prefix {{#node-1.output#}} suffix')
expect(match).toEqual({
start: 7,
end: 26,
})
expect(getMatch('plain text only')).toBeNull()
})
it('should create replacement node with mapped env/conversation/rag vars and call onInsert', () => {
const onInsert = vi.fn()
const getVarType = vi.fn(() => Type.string)
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
onInsert={onInsert}
getVarType={getVarType}
variables={variables}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
textNode: { getTextContent: () => string },
) => WorkflowVariableBlockNode
const created = createNode({
getTextContent: () => '{{#node-1.output#}}',
})
expect(onInsert).toHaveBeenCalledTimes(1)
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'output'],
workflowNodesMap,
getVarType,
variables[0].vars,
variables[1].vars,
[
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true },
],
)
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'workflow-node' })
expect(created).toEqual({ type: 'workflow-node' })
})
it('should create replacement node without optional callbacks and variable groups', () => {
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
textNode: { getTextContent: () => string },
) => WorkflowVariableBlockNode
expect(() => createNode({ getTextContent: () => '{{#node-1.output#}}' })).not.toThrow()
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'output'],
workflowNodesMap,
undefined,
[],
[],
undefined,
)
})
})

View File

@@ -167,7 +167,7 @@ const CodeEditor: FC<Props> = ({
}}
onMount={handleEditorDidMount}
/>
{!outPutValue && !isFocus && <div className="pointer-events-none absolute left-[36px] top-0 text-[13px] font-normal leading-[18px] text-gray-300">{placeholder}</div>}
{!outPutValue && !isFocus && <div className="pointer-events-none absolute left-[36px] top-0 text-[13px] font-normal leading-[18px] text-components-input-text-placeholder">{placeholder}</div>}
</>
)

View File

@@ -1295,9 +1295,6 @@
}
},
"app/components/base/audio-gallery/AudioPlayer.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -2254,11 +2251,6 @@
"count": 2
}
},
"app/components/base/notion-page-selector/credential-selector/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/notion-page-selector/page-selector/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1