mirror of
https://github.com/langgenius/dify.git
synced 2026-02-25 18:55:08 +00:00
Compare commits
9 Commits
workflow-l
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1add10aa92 | ||
|
|
daa923278e | ||
|
|
7b1b5c2445 | ||
|
|
154486bc7b | ||
|
|
fd799fa3f4 | ||
|
|
065122a2ae | ||
|
|
b5f62b98f9 | ||
|
|
0ac09127c7 | ||
|
|
3c69bac2b1 |
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
@@ -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
6
api/uv.lock
generated
@@ -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]]
|
||||
|
||||
394
web/app/components/base/audio-gallery/AudioPlayer.spec.tsx
Normal file
394
web/app/components/base/audio-gallery/AudioPlayer.spec.tsx
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
}
|
||||
|
||||
.spin-animation path:nth-child(4) {
|
||||
animation-delay: 2s;
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
|
||||
356
web/app/components/base/markdown-blocks/code-block.spec.tsx
Normal file
356
web/app/components/base/markdown-blocks/code-block.spec.tsx
Normal 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-->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()
|
||||
})
|
||||
})
|
||||
})
|
||||
164
web/app/components/base/markdown-blocks/link.spec.tsx
Normal file
164
web/app/components/base/markdown-blocks/link.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
46
web/app/components/base/markdown-blocks/music.spec.tsx
Normal file
46
web/app/components/base/markdown-blocks/music.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
96
web/app/components/base/markdown-blocks/plugin-img.spec.tsx
Normal file
96
web/app/components/base/markdown-blocks/plugin-img.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
84
web/app/components/base/markdown-blocks/video-block.spec.tsx
Normal file
84
web/app/components/base/markdown-blocks/video-block.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
54
web/app/components/base/markdown/error-boundary.spec.tsx
Normal file
54
web/app/components/base/markdown/error-boundary.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
123
web/app/components/base/markdown/index.spec.tsx
Normal file
123
web/app/components/base/markdown/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
157
web/app/components/base/markdown/markdown-utils.spec.ts
Normal file
157
web/app/components/base/markdown/markdown-utils.spec.ts
Normal 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=')
|
||||
})
|
||||
})
|
||||
271
web/app/components/base/notion-page-selector/base.spec.tsx
Normal file
271
web/app/components/base/notion-page-selector/base.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
113
web/app/components/base/prompt-editor/constants.spec.tsx
Normal file
113
web/app/components/base/prompt-editor/constants.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
162
web/app/components/base/prompt-editor/plugins/test-helpers.ts
Normal file
162
web/app/components/base/prompt-editor/plugins/test-helpers.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
17
web/app/components/base/prompt-editor/plugins/test-utils.tsx
Normal file
17
web/app/components/base/prompt-editor/plugins/test-utils.tsx
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}}' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user