refactor(bots): remove route execution test

This commit is contained in:
RockChinQ
2026-08-26 14:28:30 +08:00
parent 8b63cc0281
commit 600a173918
18 changed files with 36 additions and 1009 deletions
@@ -216,11 +216,11 @@ try {
);
await page
.getByRole("button", { name: /Test route|测试路由|ルートをテスト/ })
.getByRole("button", { name: /Check route|检查路由|ルートを確認/ })
.click();
await page.getByRole("dialog").waitFor();
await page
.getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ })
.getByRole("button", { name: /View match|查看匹配结果|一致結果を確認/ })
.click();
await page
.getByText(/Route matched|已命中路由|ルートに一致しました/)
@@ -231,28 +231,11 @@ try {
.waitFor();
result.visible_signals.push("dry-run-matched", "discard-target");
await page
.getByRole("button", {
name: /Run saved route|运行已保存路由|保存済みルートを実行/,
})
.click();
await page
.getByText(
/saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/,
)
.waitFor({ timeout: 20_000 });
result.visible_signals.push("test-event-dispatched");
await page
.getByRole("button", { name: /Close|关闭|閉じる/ })
.first()
.click();
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page
.getByText(/Discarded|已丢弃|破棄済み/)
.first()
.waitFor({ timeout: 10_000 });
result.visible_signals.push("route-status-discarded");
const text = await bodyText(page);
if (/\bEBA event\b/.test(text)) {
+1 -7
View File
@@ -64,7 +64,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
| --- | --- |
| `get_system_info` | Version, edition, instance id |
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages |
| `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
@@ -78,12 +78,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
`resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary.
`test_bot_event_route` uses the bot's saved runtime route table, injects a
synthetic event such as `message.received`, and suppresses platform delivery.
It still executes the selected processor, so tools and external services may
have side effects. Use `payload` for sample event fields, for example
`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`.
## How to use
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
@@ -90,15 +90,9 @@ class BotsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
return self.success(
data=await self.ap.bot_service.list_event_route_statuses(
request_context, bot_uuid
)
)
return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid))
async def _dry_run_event_route(
bot_uuid: str, request_context: RequestContext
) -> str:
async def _dry_run_event_route(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
if not isinstance(json_data, dict):
return self.http_status(400, -1, 'invalid request body')
@@ -128,24 +122,6 @@ class BotsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)(_dry_run_event_route)
@self.route(
'/<bot_uuid>/event-routes/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
if not isinstance(json_data, dict):
return self.http_status(400, -1, 'invalid request body')
result = await self.ap.bot_service.dispatch_test_event_route(
request_context,
bot_uuid=bot_uuid,
event_type=json_data.get('event_type'),
payload=json_data.get('event_data', json_data.get('payload')),
)
return self.success(data=result)
@self.route(
'/<bot_uuid>/send_message',
methods=['POST'],
-63
View File
@@ -21,7 +21,6 @@ class BotService:
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
FAILURE_INVALID_EVENT = 'invalid_event'
FAILURE_BOT_RUNTIME_UNAVAILABLE = 'bot_runtime_unavailable'
ROUTE_TRACE_KIND = 'event_route_trace'
BOT_FIELDS = {
@@ -798,68 +797,6 @@ class BotService:
'stale_routes': stale_routes,
}
async def dispatch_test_event_route(
self,
context: TenantContext,
bot_uuid: str,
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Dispatch a synthetic event through the saved Bot runtime route configuration."""
event_type = str(event_type or '').strip()
if not event_type:
return {
'dispatched': False,
'event_type': '',
'failure_code': self.FAILURE_INVALID_EVENT,
'reason': 'event_type is required',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
if payload is not None and not isinstance(payload, dict):
return {
'dispatched': False,
'event_type': event_type,
'failure_code': self.FAILURE_INVALID_EVENT,
'reason': 'payload must be an object',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
return {
'dispatched': False,
'event_type': event_type,
'failure_code': self.FAILURE_BOT_RUNTIME_UNAVAILABLE,
'reason': 'Bot runtime is unavailable',
'suppressed_outputs': [],
'route_status': await self.list_event_route_statuses(context, bot_uuid),
}
dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {})
route_status = await self.list_event_route_statuses(context, bot_uuid)
return {
'dispatched': bool(dispatch_result.get('dispatched')),
'event_type': event_type,
'status': dispatch_result.get('status'),
'binding_id': dispatch_result.get('binding_id'),
'failure_code': dispatch_result.get('failure_code'),
'reason': dispatch_result.get('reason'),
'suppressed_outputs': dispatch_result.get('suppressed_outputs', []),
'route_status': route_status,
}
async def send_message(
self,
context: TenantContext,
-19
View File
@@ -132,25 +132,6 @@ class LangBotMCPServer:
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
@mcp.tool(
description=(
'Dispatch a synthetic event through the saved bot event routes. '
'This validates routing without sending real outbound platform messages.'
)
)
async def test_bot_event_route(
bot_uuid: str,
event_type: str,
payload: dict | None = None,
) -> str:
return _dump(
await ap.bot_service.dispatch_test_event_route(
bot_uuid=bot_uuid,
event_type=event_type,
payload=payload,
)
)
# ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
+1 -341
View File
@@ -51,226 +51,6 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
class SyntheticRouteTestAdapter:
"""Adapter wrapper that suppresses outbound platform delivery for test events."""
SIDE_EFFECT_API_NAMES = {
'send_message',
'reply_message',
'reply_message_chunk',
'create_message_card',
'edit_message',
'delete_message',
'add_reaction',
'remove_reaction',
'forward_message',
'set_group_name',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'approve_friend_request',
'approve_group_invite',
'upload_file',
'call_platform_api',
}
def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.source = source
self.bot_account_id = getattr(source, 'bot_account_id', '')
self.config = getattr(source, 'config', {})
self.logger = getattr(source, 'logger', None)
self.suppressed_outputs: list[dict[str, typing.Any]] = []
@staticmethod
def _message_to_payload(message: platform_message.MessageChain) -> typing.Any:
return message.model_dump() if hasattr(message, 'model_dump') else str(message)
def _suppress(self, method: str, **payload: typing.Any) -> None:
self.suppressed_outputs.append({'method': method, **payload})
def __getattr__(self, name: str) -> typing.Any:
return getattr(self.source, name)
def get_supported_apis(self) -> list[str]:
get_supported_apis = getattr(self.source, 'get_supported_apis', None)
if not callable(get_supported_apis):
return []
return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> dict[str, typing.Any]:
self._suppress(
'send_message',
target_type=target_type,
target_id=target_id,
message=self._message_to_payload(message),
)
return {'suppressed': True}
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict[str, typing.Any]:
self._suppress(
'reply_message',
message=self._message_to_payload(message),
quote_origin=quote_origin,
)
return {'suppressed': True}
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict[str, typing.Any]:
self._suppress(
'reply_message_chunk',
message=self._message_to_payload(message),
quote_origin=quote_origin,
is_final=is_final,
)
return {'suppressed': True}
async def create_message_card(
self,
message_id: str | int,
event: platform_events.MessageEvent,
) -> bool:
self._suppress('create_message_card', message_id=str(message_id))
return False
async def is_stream_output_supported(self) -> bool:
return False
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
self._suppress(
'edit_message',
chat_type=str(chat_type),
chat_id=str(chat_id),
message_id=str(message_id),
new_content=self._message_to_payload(new_content),
)
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
self._suppress(
'delete_message',
chat_type=str(chat_type),
chat_id=str(chat_id),
message_id=str(message_id),
)
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
self._suppress(
'forward_message',
from_chat_type=str(from_chat_type),
from_chat_id=str(from_chat_id),
message_id=str(message_id),
to_chat_type=str(to_chat_type),
to_chat_id=str(to_chat_id),
)
return platform_events.MessageResult(raw={'suppressed': True})
async def set_group_name(
self,
group_id: typing.Union[int, str],
name: str,
) -> None:
self._suppress('set_group_name', group_id=str(group_id), name=name)
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
self._suppress(
'mute_member',
group_id=str(group_id),
user_id=str(user_id),
duration=duration,
)
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id))
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id))
async def leave_group(
self,
group_id: typing.Union[int, str],
) -> None:
self._suppress('leave_group', group_id=str(group_id))
async def approve_friend_request(
self,
request_id: typing.Union[int, str],
approve: bool = True,
remark: str | None = None,
) -> None:
self._suppress(
'approve_friend_request',
request_id=str(request_id),
approve=approve,
remark=remark,
)
async def approve_group_invite(
self,
request_id: typing.Union[int, str],
approve: bool = True,
) -> None:
self._suppress(
'approve_group_invite',
request_id=str(request_id),
approve=approve,
)
async def upload_file(self, file_data: bytes, filename: str) -> str:
self._suppress('upload_file', filename=filename, size=len(file_data))
return f'suppressed:{filename}'
async def call_platform_api(self, action: str, params: dict | None = None) -> dict:
self._suppress('call_platform_api', action=action, params=params or {})
return {'suppressed': True}
class RuntimeBot:
"""运行时机器人"""
@@ -568,126 +348,6 @@ class RuntimeBot:
"""Return the selected event binding plus per-binding diagnostic steps."""
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
@staticmethod
def _build_test_platform_event(
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> platform_events.EBAEvent:
"""Build a synthetic platform event for route validation."""
payload = payload or {}
now = time.time()
common = {
'type': event_type,
'timestamp': payload.get('timestamp') or now,
'adapter_name': payload.get('adapter_name') or 'test-event',
'source_platform_object': {'synthetic': True, 'payload': payload},
}
user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user')
user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User')
group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group')
group_name = str(payload.get('group_name') or 'Test Group')
if event_type == 'message.received':
chat_type_value = str(payload.get('chat_type') or 'private')
chat_type = (
platform_entities.ChatType.GROUP
if chat_type_value == platform_entities.ChatType.GROUP.value
else platform_entities.ChatType.PRIVATE
)
chat_id = str(
payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id)
)
message_text = str(payload.get('message_text') or payload.get('text') or '')
message_chain_data = payload.get('message_chain')
if message_chain_data is None:
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
else:
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
group = (
platform_entities.UserGroup(id=chat_id, name=group_name)
if chat_type == platform_entities.ChatType.GROUP
else None
)
return platform_events.MessageReceivedEvent(
**common,
message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'),
message_chain=message_chain,
sender=platform_entities.User(id=user_id, nickname=user_name),
chat_type=chat_type,
chat_id=chat_id,
group=group,
)
if event_type == 'group.member_joined':
return platform_events.MemberJoinedEvent(
**common,
group=platform_entities.UserGroup(id=group_id, name=group_name),
member=platform_entities.User(id=user_id, nickname=user_name),
inviter=platform_entities.User(
id=str(payload.get('inviter_id')),
nickname=str(payload.get('inviter_name') or ''),
)
if payload.get('inviter_id')
else None,
join_type=payload.get('join_type'),
)
if event_type == 'group.member_left':
return platform_events.MemberLeftEvent(
**common,
group=platform_entities.UserGroup(id=group_id, name=group_name),
member=platform_entities.User(id=user_id, nickname=user_name),
is_kicked=bool(payload.get('is_kicked', False)),
operator=platform_entities.User(
id=str(payload.get('operator_id')),
nickname=str(payload.get('operator_name') or ''),
)
if payload.get('operator_id')
else None,
)
if event_type == 'platform.specific':
return platform_events.PlatformSpecificEvent(
**common,
action=str(payload.get('action') or 'test'),
data=payload.get('data') if isinstance(payload.get('data'), dict) else payload,
)
return platform_events.EBAEvent(**common)
async def dispatch_test_event(
self,
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Dispatch a synthetic event through the real runtime route path."""
event_type = str(event_type or '').strip()
if not event_type:
raise ValueError('event_type is required')
event = self._build_test_platform_event(event_type, payload)
await self._record_event_route_trace(
event_type=event_type,
status='test_started',
reason='Synthetic test event dispatched from control plane',
text=f'Test event {event_type} dispatched from control plane',
)
test_adapter = SyntheticRouteTestAdapter(self.adapter)
outcome = await self._dispatch_eba_event_to_processor(
event,
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
)
return {
'event_type': event_type,
'dispatched': outcome['status'] in {'delivered', 'discarded'},
'status': outcome['status'],
'binding_id': outcome.get('binding_id'),
'failure_code': outcome.get('failure_code'),
'reason': outcome.get('reason'),
'suppressed_outputs': test_adapter.suppressed_outputs,
}
async def _record_event_route_trace(
self,
*,
@@ -1170,7 +830,7 @@ class RuntimeBot:
self,
envelope: AgentEventEnvelope,
outputs: list[provider_message.Message | provider_message.MessageChunk],
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
) -> None:
if not outputs or not envelope.delivery.reply_target:
return
-41
View File
@@ -171,18 +171,6 @@ def fake_bot_app():
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
}
)
app.bot_service.dispatch_test_event_route = AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
)
app.bot_service.send_message = AsyncMock()
# Platform manager
@@ -373,35 +361,6 @@ class TestBotEventRouteStatusEndpoint:
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEventRouteTestEndpoint:
"""Tests for bot event route synthetic dispatch endpoint."""
@pytest.mark.asyncio
async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app):
"""POST test route dispatches a synthetic event."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/event-routes/test',
headers={'Authorization': 'Bearer test_token'},
json={
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['dispatched'] is True
assert data['data']['event_type'] == 'message.received'
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
ANY,
bot_uuid='test-bot-uuid',
event_type='message.received',
payload={'message_text': 'hello'},
)
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
+1 -27
View File
@@ -56,18 +56,6 @@ def build_ap() -> SimpleNamespace:
ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
dispatch_test_event_route=AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
),
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
@@ -126,7 +114,7 @@ async def main() -> int:
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f'PASS: listed {len(names)} tools')
for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'):
for required in ('list_bots', 'get_system_info', 'list_skills'):
if required not in names:
failures.append(f'missing tool {required}')
@@ -144,20 +132,6 @@ async def main() -> int:
else:
print('PASS: get_system_info returned version')
res3 = await session.call_tool(
'test_bot_event_route',
{
'bot_uuid': 'bot-1',
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
text3 = res3.content[0].text if res3.content else ''
if '"dispatched": true' not in text3:
failures.append(f'test_bot_event_route wrong: {text3!r}')
else:
print('PASS: test_bot_event_route returned dispatch result')
shutdown.set()
with contextlib.suppress(Exception):
await asyncio.wait_for(server_task, timeout=5)
@@ -656,46 +656,6 @@ class TestBotServiceListEventRouteStatuses:
assert result['stale_routes'] == []
class TestBotServiceDispatchTestEventRoute:
"""Tests for dispatching a synthetic event through a saved route."""
async def test_returns_actionable_failure_when_runtime_bot_is_unavailable(self):
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
service.list_event_route_statuses = AsyncMock(
return_value={
'routes': [],
'unmatched_events': [],
'stale_routes': [],
}
)
result = await service.dispatch_test_event_route(
WORKSPACE_UUID,
'bot-uuid',
'message.received',
{'message_text': 'Hello'},
)
assert result == {
'dispatched': False,
'event_type': 'message.received',
'failure_code': 'bot_runtime_unavailable',
'reason': 'Bot runtime is unavailable',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
service.list_event_route_statuses.assert_awaited_once_with(WORKSPACE_UUID, 'bot-uuid')
class TestBotServiceSendMessage:
"""Tests for send_message method."""
+1 -38
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -20,18 +19,6 @@ def _make_app() -> SimpleNamespace:
update_bot=AsyncMock(),
delete_bot=AsyncMock(),
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
dispatch_test_event_route=AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
),
)
app.pipeline_service = SimpleNamespace(
get_pipelines=AsyncMock(return_value=[]),
@@ -75,30 +62,6 @@ async def test_mcp_server_exposes_bot_event_route_tools():
tool_names = {tool.name for tool in tools}
assert 'list_bot_event_route_statuses' in tool_names
assert 'test_bot_event_route' in tool_names
assert 'test_bot_event_route' not in tool_names
assert 'list_processors' in tool_names
assert 'list_agents' not in tool_names
@pytest.mark.asyncio
async def test_mcp_test_bot_event_route_calls_service_layer():
app = _make_app()
server = LangBotMCPServer(app)
result_blocks, _ = await server.mcp.call_tool(
'test_bot_event_route',
{
'bot_uuid': 'bot-1',
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
app.bot_service.dispatch_test_event_route.assert_awaited_once_with(
bot_uuid='bot-1',
event_type='message.received',
payload={'message_text': 'hello'},
)
data = json.loads(result_blocks[0].text)
assert data['dispatched'] is True
assert data['event_type'] == 'message.received'
@@ -95,63 +95,6 @@ class TestEventRouteTrace:
assert metadata['target_uuid'] == 'agent-1'
assert metadata['status'] == 'failed'
@pytest.mark.asyncio
async def test_dispatch_test_event_suppresses_agent_output_delivery(self):
"""Synthetic test dispatch runs the route but does not call the real adapter."""
import langbot_plugin.api.entities.builtin.provider.message as provider_message
captured_envelopes = []
async def fake_run(envelope, binding, adapter_context=None):
captured_envelopes.append(envelope)
yield provider_message.Message(role='assistant', content='test response')
bot = self._make_bot(
[
{
'id': 'agent-binding',
'enabled': True,
'event_pattern': 'message.received',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': 0,
'order': 0,
}
]
)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': 'agent',
'enabled': True,
'supported_event_patterns': ['message.received'],
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
}
)
),
agent_run_orchestrator=SimpleNamespace(run=fake_run),
)
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
send_message=AsyncMock(),
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']),
)
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'})
bot.adapter.send_message.assert_not_awaited()
assert result['dispatched'] is True
assert result['status'] == 'delivered'
assert result['suppressed_outputs'][0]['method'] == 'send_message'
assert captured_envelopes[0].delivery.supports_edit is False
assert captured_envelopes[0].delivery.supports_reaction is False
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info']
@pytest.mark.asyncio
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
@@ -208,128 +151,6 @@ class TestEventRouteTrace:
assert delivered['status'] == 'delivered'
assert len(runner_calls) == 1
@pytest.mark.asyncio
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
"""Pipeline route tests enqueue queries with the no-op adapter."""
bot = self._make_bot(
[
{
'id': 'pipeline-binding',
'enabled': True,
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
'priority': 0,
'order': 0,
}
]
)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
)
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
send_message=AsyncMock(),
)
result = await bot.dispatch_test_event(
'message.received',
{'chat_id': 'user-1', 'message_text': 'hello'},
)
bot.adapter.send_message.assert_not_awaited()
bot.ap.msg_aggregator.add_message.assert_awaited_once()
_, kwargs = bot.ap.msg_aggregator.add_message.await_args
query_adapter = kwargs['adapter']
assert query_adapter is not bot.adapter
assert getattr(query_adapter, 'source') is bot.adapter
assert result['dispatched'] is True
assert result['status'] == 'delivered'
assert result['suppressed_outputs'] == []
@pytest.mark.asyncio
async def test_dispatch_test_event_reports_unmatched_route_as_failure(self):
"""Synthetic dispatch does not report success when no saved route matches."""
bot = self._make_bot([])
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
)
result = await bot.dispatch_test_event(
'message.received',
{'chat_id': 'user-1', 'message_text': 'hello'},
)
assert result['dispatched'] is False
assert result['status'] == 'not_matched'
assert result['failure_code'] == 'route_not_found'
assert result['reason'] == 'No event route matched'
@pytest.mark.asyncio
async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self):
"""Synthetic adapter blocks optional platform APIs that mutate external state."""
from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
source = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=Mock(),
get_supported_apis=Mock(
return_value=[
'send_message',
'delete_message',
'get_group_info',
'call_platform_api',
]
),
delete_message=AsyncMock(),
call_platform_api=AsyncMock(),
)
adapter = SyntheticRouteTestAdapter(source)
await adapter.delete_message('group', 'group-1', 'message-1')
await adapter.call_platform_api('set_title', {'name': 'New Title'})
upload_result = await adapter.upload_file(b'data', 'test.txt')
source.delete_message.assert_not_awaited()
source.call_platform_api.assert_not_awaited()
assert upload_result == 'suppressed:test.txt'
assert [item['method'] for item in adapter.suppressed_outputs] == [
'delete_message',
'call_platform_api',
'upload_file',
]
assert adapter.get_supported_apis() == ['get_group_info']
assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')]))
def test_build_test_platform_event_message_received_uses_payload(self):
"""Synthetic message events preserve common route filter fields."""
from langbot.pkg.platform.botmgr import RuntimeBot
event = RuntimeBot._build_test_platform_event(
'message.received',
{
'chat_type': 'group',
'chat_id': 'group-1',
'group_name': 'QA Group',
'user_id': 'user-1',
'user_name': 'QA User',
'message_text': 'hello',
},
)
assert event.type == 'message.received'
assert str(event.chat_id) == 'group-1'
assert event.group.name == 'QA Group'
assert event.sender.nickname == 'QA User'
assert str(event.message_chain) == 'hello'
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
"""Runner delivery context reflects the active adapter's declared APIs."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
@@ -112,7 +112,6 @@ import {
Agent,
BotRouteDryRunResult,
BotEventRouteStatus,
BotRouteTestResult,
} from '@/app/infra/entities/api';
import { backendClient } from '@/app/infra/http';
import {
@@ -885,13 +884,11 @@ function RouteDryRunDialog({
bindings,
eventOptions,
agentOptions,
onRouteStatusUpdate,
}: {
botId?: string;
bindings: EventBinding[];
eventOptions: string[];
agentOptions: Agent[];
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
}) {
const { t } = useTranslation();
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
@@ -902,12 +899,9 @@ function RouteDryRunDialog({
);
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isDispatching, setIsDispatching] = useState(false);
const [payloadError, setPayloadError] = useState<string | null>(null);
const [runError, setRunError] = useState<string | null>(null);
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
const [dispatchResult, setDispatchResult] =
useState<BotRouteTestResult | null>(null);
useEffect(() => {
if (!eventOptions.includes(eventType)) {
@@ -919,7 +913,6 @@ function RouteDryRunDialog({
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
setPayloadError(null);
setResult(null);
setDispatchResult(null);
}, [eventType]);
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
@@ -956,7 +949,6 @@ function RouteDryRunDialog({
async function runDryRun() {
setRunError(null);
setResult(null);
setDispatchResult(null);
const payload = parsePayload();
if (payload === null) return;
@@ -985,44 +977,6 @@ function RouteDryRunDialog({
}
}
async function dispatchTestEvent() {
setRunError(null);
setResult(null);
setDispatchResult(null);
const payload = parsePayload();
if (payload === null) return;
if (!botId) {
setRunError(t('bots.dryRunNeedsSavedBot'));
return;
}
setIsDispatching(true);
try {
const testResult = await backendClient.testBotEventRoute(botId, {
event_type: eventType,
payload,
});
setDispatchResult(testResult);
onRouteStatusUpdate?.(testResult.route_status?.routes || []);
if (!testResult.dispatched) {
setRunError(
localizedFailureReason(
testResult.failure_code,
testResult.reason,
t,
) || t('bots.routeTestFailed'),
);
}
} catch (error) {
const err = error as { msg?: string };
setRunError(err.msg || t('bots.routeTestFailed'));
} finally {
setIsDispatching(false);
}
}
const targetName = result ? resolveTargetName(result.target) : '';
return (
@@ -1206,24 +1160,6 @@ function RouteDryRunDialog({
)}
</div>
)}
{dispatchResult?.dispatched && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>
{t('bots.routeTestDispatched', {
count: dispatchResult.suppressed_outputs?.length || 0,
})}
</AlertDescription>
</Alert>
)}
<Alert className="border-amber-200 bg-amber-50/60 px-3 py-2 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{t('bots.routeTestSideEffectWarning')}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
@@ -1234,25 +1170,10 @@ function RouteDryRunDialog({
>
{t('common.close')}
</Button>
<Button
type="button"
onClick={runDryRun}
disabled={isRunning || isDispatching}
>
<Button type="button" onClick={runDryRun} disabled={isRunning}>
<Play className="h-4 w-4 mr-1" />
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
</Button>
<Button
type="button"
variant="outline"
onClick={dispatchTestEvent}
disabled={isRunning || isDispatching}
>
<Activity className="h-4 w-4 mr-1" />
{isDispatching
? t('bots.routeTestRunning')
: t('bots.routeTestAction')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1878,7 +1799,6 @@ export default function EventBindingsEditor({
bindings={bindings}
eventOptions={dryRunEventOptions}
agentOptions={agentOptions}
onRouteStatusUpdate={setRouteStatuses}
/>
<Tooltip>
<TooltipTrigger asChild>
-16
View File
@@ -275,11 +275,6 @@ export interface BotRouteDryRunRequest {
event_bindings?: EventBinding[];
}
export interface BotRouteTestRequest {
event_type: string;
payload?: Record<string, unknown>;
}
export interface BotRouteDryRunTarget {
target_type: EventBinding['target_type'];
target_uuid?: string | null;
@@ -334,17 +329,6 @@ export interface BotEventRouteStatusResponse {
stale_routes: BotEventRouteStatus[];
}
export interface BotRouteTestResult {
dispatched: boolean;
event_type: string;
status?: BotEventRouteStatus['last_status'];
binding_id?: string | null;
failure_code?: string | null;
reason?: string | null;
suppressed_outputs: Array<Record<string, unknown>>;
route_status: BotEventRouteStatusResponse;
}
export interface ApiRespKnowledgeBases {
bases: KnowledgeBase[];
}
-12
View File
@@ -61,8 +61,6 @@ import {
ApiRespSkill,
BotRouteDryRunRequest,
BotRouteDryRunResult,
BotRouteTestRequest,
BotRouteTestResult,
BotEventRouteStatusResponse,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
@@ -512,16 +510,6 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
}
public testBotEventRoute(
botId: string,
request: BotRouteTestRequest,
): Promise<BotRouteTestResult> {
return this.post(
`/api/v1/platform/bots/${botId}/event-routes/test`,
request,
);
}
public deleteBot(uuid: string): Promise<object> {
return this.delete(`/api/v1/platform/bots/${uuid}`);
}
+7 -18
View File
@@ -450,7 +450,7 @@ const enUS = {
routeFallbackCatchAll: '{{route}} is the catch-all route.',
routeFallbackIgnored:
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
testRoute: 'Test route',
testRoute: 'Check route',
refreshRouteStatus: 'Refresh status',
routeStatusIdle: 'No run yet',
routeStatusRefreshFailed: 'Failed to refresh route status.',
@@ -460,7 +460,6 @@ const enUS = {
discarded: 'Discarded',
failed: 'Failed',
not_matched: 'Not matched',
test_started: 'Testing',
},
routeStatusDetail: {
matched: 'This route matched the event.',
@@ -468,7 +467,6 @@ const enUS = {
discarded: 'The event was intentionally discarded.',
failed: 'The route could not finish.',
not_matched: 'No configured route matched the event.',
test_started: 'The saved route is running.',
},
routeFailure: {
binding_disabled: 'This route is disabled.',
@@ -479,21 +477,12 @@ const enUS = {
processor_incompatible:
'The selected processor cannot handle this event.',
processor_not_found: 'The selected processor is unavailable.',
bot_runtime_unavailable:
'The bot is not running. Check its platform settings and enable it before running a full test.',
runner_failed: 'The Agent runner failed while processing the event.',
delivery_failed: 'The processor finished, but delivery failed.',
},
routeTestAction: 'Run full test',
routeTestRunning: 'Running…',
routeTestFailed: 'Failed to run the saved route. Try again later.',
routeTestDispatched:
'The saved route ran successfully. {{count}} platform actions were blocked.',
routeTestSideEffectWarning:
'The processor and tools will run, but replies will not be sent to messaging platforms.',
dryRunTitle: 'Test route',
dryRunTitle: 'Check event route',
dryRunDescription:
'Choose an event to see which processor handles it, or run a test.',
'Choose an event to see which route and processor it matches.',
dryRunEventType: 'Event type',
dryRunSampleReady: 'Sample event is ready',
dryRunSampleDescription:
@@ -504,10 +493,10 @@ const enUS = {
dryRunPayloadHint: 'Use this to test message and conversation conditions.',
dryRunPayloadJsonError: 'Enter valid JSON.',
dryRunPayloadObjectError: 'Payload must be a JSON object.',
dryRunNeedsSavedBot: 'Save the bot before testing routes.',
dryRunFailed: 'Route test failed. Try again later.',
dryRunAction: 'Preview match',
dryRunRunning: 'Testing…',
dryRunNeedsSavedBot: 'Save the bot before checking routes.',
dryRunFailed: 'Failed to check the route. Try again later.',
dryRunAction: 'View match',
dryRunRunning: 'Checking…',
dryRunMatched: 'Route matched',
dryRunNotMatched: 'No route matched',
dryRunTarget: 'Target processor',
+7 -20
View File
@@ -457,7 +457,7 @@ const jaJP = {
routeFallbackCatchAll: '{{route}} はフォールバックルートです。',
routeFallbackIgnored:
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
testRoute: 'ルートをテスト',
testRoute: 'ルートを確認',
refreshRouteStatus: '状態を更新',
routeStatusIdle: '実行記録なし',
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
@@ -467,7 +467,6 @@ const jaJP = {
discarded: '破棄済み',
failed: '失敗',
not_matched: '未一致',
test_started: 'テスト中',
},
routeStatusDetail: {
matched: 'このルートがイベントに一致しました。',
@@ -475,7 +474,6 @@ const jaJP = {
discarded: '設定に従ってイベントを破棄しました。',
failed: 'ルートを完了できませんでした。',
not_matched: '設定済みルートに一致しませんでした。',
test_started: '保存済みルートを実行しています。',
},
routeFailure: {
binding_disabled: 'このルートは無効です。',
@@ -486,22 +484,11 @@ const jaJP = {
processor_incompatible:
'選択したプロセッサーはこのイベントを処理できません。',
processor_not_found: '選択したプロセッサーを利用できません。',
bot_runtime_unavailable:
'ボットが実行されていません。プラットフォーム設定を確認してボットを有効にした後、完全テストを実行してください。',
runner_failed: 'Agent Runner がイベント処理中に失敗しました。',
delivery_failed: '処理は完了しましたが、結果の配信に失敗しました。',
},
routeTestAction: '完全テストを実行',
routeTestRunning: '実行中…',
routeTestFailed:
'保存済みルートの実行に失敗しました。後でもう一度お試しください。',
routeTestDispatched:
'保存済みルートを実行しました。{{count}} 件のプラットフォーム操作を抑制しました。',
routeTestSideEffectWarning:
'プロセッサーとツールは実行されますが、返信はメッセージプラットフォームへ送信されません。',
dryRunTitle: 'ルートをテスト',
dryRunDescription:
'イベントを選び、処理先を確認するか、テストを実行します。',
dryRunTitle: 'イベントルートを確認',
dryRunDescription: 'イベントを選び、一致するルートと処理先を確認します。',
dryRunEventType: 'イベントタイプ',
dryRunSampleReady: 'サンプルイベントを準備しました',
dryRunSampleDescription:
@@ -513,10 +500,10 @@ const jaJP = {
dryRunPayloadJsonError: '有効な JSON を入力してください。',
dryRunPayloadObjectError:
'ペイロードは JSON オブジェクトである必要があります。',
dryRunNeedsSavedBot: 'ルートをテストする前にボットを保存してください。',
dryRunFailed: 'ルートテストに失敗しました。後でもう一度お試しください。',
dryRunAction: '一致を確認',
dryRunRunning: 'テスト中…',
dryRunNeedsSavedBot: 'ルートを確認する前にボットを保存してください。',
dryRunFailed: 'ルートを確認できませんでした。後でもう一度お試しください。',
dryRunAction: '一致結果を確認',
dryRunRunning: '確認中…',
dryRunMatched: 'ルートに一致しました',
dryRunNotMatched: '一致するルートはありません',
dryRunTarget: '対象プロセッサー',
+7 -17
View File
@@ -429,7 +429,7 @@ const zhHans = {
routeFallbackCatchAll: '{{route}} 是兜底路由。',
routeFallbackIgnored:
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
testRoute: '测试路由',
testRoute: '检查路由',
refreshRouteStatus: '刷新状态',
routeStatusIdle: '暂无运行记录',
routeStatusRefreshFailed: '刷新路由状态失败。',
@@ -439,7 +439,6 @@ const zhHans = {
discarded: '已丢弃',
failed: '失败',
not_matched: '未命中',
test_started: '测试中',
},
routeStatusDetail: {
matched: '此路由已命中事件。',
@@ -447,7 +446,6 @@ const zhHans = {
discarded: '此事件已按路由配置丢弃。',
failed: '此路由未能完成。',
not_matched: '没有已配置路由命中此事件。',
test_started: '正在运行已保存路由。',
},
routeFailure: {
binding_disabled: '此路由已禁用。',
@@ -457,19 +455,11 @@ const zhHans = {
route_not_found: '没有路由命中此事件。',
processor_incompatible: '所选处理器无法处理此事件。',
processor_not_found: '所选处理器不可用。',
bot_runtime_unavailable:
'机器人尚未运行。请检查平台配置并启用机器人,再运行完整测试。',
runner_failed: 'Agent Runner 处理事件时失败。',
delivery_failed: '处理器已完成,但结果投递失败。',
},
routeTestAction: '运行完整测试',
routeTestRunning: '运行中…',
routeTestFailed: '运行已保存路由失败,请稍后重试。',
routeTestDispatched: '已保存路由运行成功,{{count}} 个平台操作已被阻止。',
routeTestSideEffectWarning:
'测试会运行处理器和工具,但不会把回复发送到微信、QQ 等聊天平台。',
dryRunTitle: '测试路由',
dryRunDescription: '选择事件,查看它会交给哪个处理器,或运行一次测试。',
dryRunTitle: '检查事件路由',
dryRunDescription: '选择事件,查看它会匹配哪条路由、交给哪个处理器。',
dryRunEventType: '事件类型',
dryRunSampleReady: '示例事件已准备好',
dryRunSampleDescription:
@@ -480,10 +470,10 @@ const zhHans = {
dryRunPayloadHint: '用于测试消息内容、会话类型等条件。',
dryRunPayloadJsonError: '请输入合法 JSON。',
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
dryRunNeedsSavedBot: '请先保存机器人后再测试路由。',
dryRunFailed: '路由测试失败,请稍后重试。',
dryRunAction: '预览匹配',
dryRunRunning: '测试中…',
dryRunNeedsSavedBot: '请先保存机器人后再检查路由。',
dryRunFailed: '无法检查路由,请稍后重试。',
dryRunAction: '查看匹配结果',
dryRunRunning: '检查中…',
dryRunMatched: '已命中路由',
dryRunNotMatched: '未命中路由',
dryRunTarget: '目标处理器',
+6 -45
View File
@@ -389,29 +389,6 @@ test.describe('bot advanced flows', () => {
}),
}),
);
await page.route('**/api/v1/platform/bots/*/event-routes/test', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: {
dispatched: false,
event_type: 'message.received',
failure_code: 'bot_runtime_unavailable',
reason: 'Bot runtime is unavailable',
suppressed_outputs: [],
route_status: {
routes: [],
unmatched_events: [],
stale_routes: [],
},
},
}),
}),
);
await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Route Status Bot');
@@ -454,14 +431,14 @@ test.describe('bot advanced flows', () => {
page.getByText('Failed to refresh route status.'),
).toBeVisible();
await page.getByRole('button', { name: 'Test route' }).click();
await page.getByRole('button', { name: 'Check route' }).click();
const routeDialog = page.getByRole('dialog');
await expect(
routeDialog.getByText('Test route', { exact: true }),
routeDialog.getByText('Check event route', { exact: true }),
).toBeVisible();
await expect(
routeDialog.getByText(
'Choose an event to see which processor handles it, or run a test.',
'Choose an event to see which route and processor it matches.',
),
).toBeVisible();
await expect(routeDialog.getByText('Sample event is ready')).toHaveCount(0);
@@ -469,11 +446,11 @@ test.describe('bot advanced flows', () => {
routeDialog.getByRole('button', { name: 'Test data' }),
).toBeVisible();
await expect(
routeDialog.getByRole('button', { name: 'Preview match' }),
routeDialog.getByRole('button', { name: 'View match' }),
).toBeVisible();
await expect(
routeDialog.getByRole('button', { name: 'Run full test' }),
).toBeVisible();
).toHaveCount(0);
const routeEventPicker = routeDialog.getByRole('combobox', {
name: 'Event type',
});
@@ -494,25 +471,9 @@ test.describe('bot advanced flows', () => {
);
await page.keyboard.press('Escape');
await routeDialog.getByRole('button', { name: 'Preview match' }).click();
await routeDialog.getByRole('button', { name: 'View match' }).click();
await expect(routeDialog.getByText('Matched route')).toBeVisible();
await routeDialog.getByRole('button', { name: 'Run full test' }).click();
await expect(routeDialog.getByText('Matched route')).toHaveCount(0);
await expect(
routeDialog.getByText(
'The bot is not running. Check its platform settings and enable it before running a full test.',
),
).toBeVisible();
await expect(routeDialog.getByText('Internal server error')).toHaveCount(0);
await routeDialog.getByRole('button', { name: 'Preview match' }).click();
await expect(
routeDialog.getByText(
'The bot is not running. Check its platform settings and enable it before running a full test.',
),
).toHaveCount(0);
await expect(routeDialog.getByText('Matched route')).toBeVisible();
const dialogBox = await routeDialog.boundingBox();
expect(dialogBox).not.toBeNull();
expect(dialogBox!.height).toBeLessThan(500);