feat(bots): add platform event debugger

This commit is contained in:
RockChinQ
2026-08-26 19:46:59 +08:00
parent 7990d36c78
commit 3d692fa8db
12 changed files with 586 additions and 14 deletions
@@ -188,11 +188,7 @@ try {
.getByText(/Event Routing|事件路由|イベントルーティング/)
.first()
.waitFor({ timeout: 15_000 });
await page
.getByText(
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
)
.waitFor();
await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
await page
.getByText(/Message received|收到消息|メッセージを受信/)
.first()
@@ -237,6 +233,57 @@ try {
.click();
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page
.getByRole("button", {
name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/,
})
.click();
const adapterDialog = page.getByRole("dialog");
await adapterDialog.waitFor();
await adapterDialog
.getByText(/Listening|正在监听|監視中/, { exact: true })
.waitFor({ timeout: 15_000 });
const inboundText = `adapter event ${paths.runId}`;
const inbound = await apiJson(
backendUrl,
`/bots/${encodeURIComponent(botId)}`,
{
method: "POST",
token,
body: {
session_id: `adapter-debug-${paths.runId}`,
session_type: "person",
sender: { id: "adapter-debug-user", name: "Adapter QA" },
message: [{ type: "Plain", text: inboundText }],
},
},
);
result.api.adapter_event_webhook = {
http_status: inbound.status,
code: inbound.json.code ?? null,
};
if (inbound.status >= 400 || inbound.json.code !== 0) {
throw new Error(
inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.",
);
}
await adapterDialog
.getByText(/Message received|收到消息|メッセージ受信/, { exact: true })
.waitFor({ timeout: 15_000 });
await adapterDialog.getByText("message.received", { exact: true }).waitFor();
await adapterDialog.getByText(inboundText, { exact: true }).waitFor();
result.visible_signals.push(
"adapter-event-listening",
"adapter-event-received",
"adapter-event-raw-code",
);
await adapterDialog
.getByRole("button", { name: /Close|关闭|閉じる/ })
.click();
await adapterDialog.waitFor({ state: "hidden" });
const text = await bodyText(page);
if (/\bEBA event\b/.test(text)) {
throw new Error(
@@ -291,7 +338,7 @@ try {
}
result.status = "pass";
result.reason =
"Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI.";
"Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI.";
} catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message;
@@ -1,5 +1,5 @@
id: bot-event-routing-product-flow
title: "Bot event routing can be configured and tested from the WebUI"
title: "Bot event routing and adapter input can be inspected from the WebUI"
mode: agent-browser
area: bot
type: feature
@@ -33,16 +33,18 @@ steps:
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
- "Open Test event route and run a dry-run against the current form."
- "Run the saved runtime route with a synthetic event."
- "Close the dialog and confirm the route card shows the latest discarded status."
- "Open Platform event debugging and send a real inbound event through the HTTP Bot adapter."
- "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
checks:
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
- "UI: Dry-run visibly reports that the route matched the discard processor."
- "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded."
- "UI: Adapter event debugging is clearly separate from route preview and starts listening only after the dialog opens."
- "UI: A real adapter event shows its friendly name, raw code, and normalized event data."
- "UI: The route card updates to discarded after the real inbound event is handled."
- "Console: No unexpected frontend errors appear during the flow."
- "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses."
- "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses."
- "Cleanup: The temporary Bot is deleted after evidence is collected."
evidence_required:
- ui
@@ -51,7 +53,8 @@ evidence_required:
- api_diagnostic
diagnostics:
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
- "A passing API call without the visible matched and discarded UI states is not a pass."
- "The adapter dialog observes normalized platform events; it does not simulate route matching."
- "A passing webhook call without the visible adapter event and discarded UI states is not a pass."
troubleshooting:
- backend-not-listening
- proxy-env-mismatch
+23 -1
View File
@@ -444,6 +444,26 @@ class RuntimeBot:
compact[key] = value
return compact
async def _record_adapter_event(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> dict[str, typing.Any]:
"""Record a normalized adapter event for the platform debugging surface."""
event_type = getattr(event, 'type', None) or event.__class__.__name__
metadata = {
'kind': 'adapter_event_received',
'event_type': event_type,
'event_data': self._compact_event_data(event),
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
'bot_uuid': self.bot_entity.uuid,
}
await self.logger.info(
f'Platform adapter received {event_type}',
metadata=metadata,
)
return metadata
@staticmethod
def _get_entity_id(entity: typing.Any) -> str | None:
entity_id = getattr(entity, 'id', None)
@@ -864,11 +884,13 @@ class RuntimeBot:
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None:
event.bot_uuid = self.bot_entity.uuid
await self._record_adapter_event(event, adapter)
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
await self._handle_interaction_submission(event, adapter)
return
event.bot_uuid = self.bot_entity.uuid
plugin_event = self._eba_event_to_plugin_event(event)
if plugin_event is not None:
@@ -95,6 +95,36 @@ class TestEventRouteTrace:
assert metadata['target_uuid'] == 'agent-1'
assert metadata['status'] == 'failed'
@pytest.mark.asyncio
async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
"""Adapter debugging records the shared event shape without opaque SDK data."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
bot = self._make_bot([])
bot.bot_entity.adapter = 'test-adapter'
event = events.MessageReceivedEvent(
message_id='message-1',
message_chain=message.MessageChain([message.Plain(text='hello')]),
sender=entities.User(id='user-1', nickname='QA User'),
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
source_platform_object={'access_token': 'must-not-be-logged'},
)
metadata = await bot._record_adapter_event(event, SimpleNamespace())
assert metadata['kind'] == 'adapter_event_received'
assert metadata['event_type'] == 'message.received'
assert metadata['adapter'] == 'test-adapter'
assert metadata['bot_uuid'] == 'bot-1'
assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
assert metadata['event_data']['sender']['id'] == 'user-1'
assert 'source_platform_object' not in metadata['event_data']
bot.logger.info.assert_awaited_once_with(
'Platform adapter received message.received',
metadata=metadata,
)
@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."""
@@ -0,0 +1,335 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Activity,
AlertCircle,
ChevronDown,
RadioTower,
Trash2,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import {
eventPatternDescription,
eventPatternLabel,
} from '@/app/home/components/event-patterns/event-pattern-groups';
const POLL_INTERVAL_MS = 1200;
const MAX_VISIBLE_EVENTS = 50;
interface ObservedAdapterEvent {
seqId: number;
timestamp: number;
eventType: string;
eventData: Record<string, unknown>;
}
type ListenerState = 'preparing' | 'listening' | 'error';
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null {
const metadata = log.metadata;
if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') {
return null;
}
const eventType = metadata.event_type;
if (typeof eventType !== 'string' || !eventType) return null;
return {
seqId: log.seq_id,
timestamp: log.timestamp,
eventType,
eventData: isRecord(metadata.event_data) ? metadata.event_data : {},
};
}
function findEventPreview(value: unknown, depth = 0): string | null {
if (depth > 4 || value === null || value === undefined) return null;
if (Array.isArray(value)) {
for (const item of value) {
const preview = findEventPreview(item, depth + 1);
if (preview) return preview;
}
return null;
}
if (!isRecord(value)) return null;
for (const key of ['message_text', 'text', 'action']) {
const candidate = value[key];
if (typeof candidate === 'string' && candidate.trim()) {
const trimmed = candidate.trim();
return trimmed.length > 160 ? `${trimmed.slice(0, 160)}` : trimmed;
}
}
for (const child of Object.values(value)) {
const preview = findEventPreview(child, depth + 1);
if (preview) return preview;
}
return null;
}
export default function AdapterEventDebugDialog({
botId,
adapterLabel,
}: {
botId?: string;
adapterLabel: string;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [listenerState, setListenerState] =
useState<ListenerState>('preparing');
const [events, setEvents] = useState<ObservedAdapterEvent[]>([]);
const baselineSeqRef = useRef<number | null>(null);
const pollInFlightRef = useRef(false);
const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform');
const pollLogs = useCallback(async () => {
if (!botId || pollInFlightRef.current) return;
pollInFlightRef.current = true;
try {
const response = await backendClient.getBotLogs(botId, {
from_index: -1,
max_count: 100,
});
const latestSeq = response.logs.reduce(
(maximum, log) => Math.max(maximum, log.seq_id),
-1,
);
if (baselineSeqRef.current === null) {
baselineSeqRef.current = latestSeq;
setListenerState('listening');
return;
}
const baseline = baselineSeqRef.current;
const newlyObserved = response.logs
.filter((log) => log.seq_id > baseline)
.map(observedEventFromLog)
.filter((event): event is ObservedAdapterEvent => event !== null);
if (newlyObserved.length > 0) {
setEvents((current) => {
const bySeqId = new Map(
[...newlyObserved, ...current].map((event) => [event.seqId, event]),
);
return Array.from(bySeqId.values())
.sort((left, right) => right.seqId - left.seqId)
.slice(0, MAX_VISIBLE_EVENTS);
});
}
baselineSeqRef.current = Math.max(baseline, latestSeq);
setListenerState('listening');
} catch {
setListenerState('error');
} finally {
pollInFlightRef.current = false;
}
}, [botId]);
useEffect(() => {
if (!open || !botId) return;
baselineSeqRef.current = null;
pollInFlightRef.current = false;
setEvents([]);
setListenerState('preparing');
void pollLogs();
const interval = window.setInterval(
() => void pollLogs(),
POLL_INTERVAL_MS,
);
return () => window.clearInterval(interval);
}, [botId, open, pollLogs]);
const status = useMemo(() => {
if (listenerState === 'error') {
return {
text: t('bots.adapterEventListenerUnavailable'),
dot: 'bg-destructive',
};
}
if (listenerState === 'listening') {
return {
text: t('bots.adapterEventListening'),
dot: 'bg-emerald-500',
};
}
return {
text: t('bots.adapterEventPreparing'),
dot: 'bg-amber-500',
};
}, [listenerState, t]);
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={!botId}
onClick={() => setOpen(true)}
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
>
<RadioTower className="mr-1 h-4 w-4" />
{t('bots.adapterEventDebugAction')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex flex-wrap items-center gap-2 pr-8">
<DialogTitle>{t('bots.adapterEventDebugTitle')}</DialogTitle>
<Badge variant="outline" className="gap-1.5 font-normal">
<span className={`size-2 rounded-full ${status.dot}`} />
{status.text}
</Badge>
</div>
<DialogDescription>
{t('bots.adapterEventDebugDescription', {
platform: platformName,
})}
</DialogDescription>
</DialogHeader>
<Alert className="bg-muted/30">
<Activity className="h-4 w-4" />
<AlertDescription>
{t('bots.adapterEventObserveOnly')}
</AlertDescription>
</Alert>
{listenerState === 'error' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('bots.adapterEventLoadFailed')}
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t('bots.adapterEventReceivedCount', { count: events.length })}
</p>
<Button
type="button"
variant="ghost"
size="sm"
disabled={events.length === 0}
onClick={() => setEvents([])}
>
<Trash2 className="mr-1 h-4 w-4" />
{t('bots.adapterEventClear')}
</Button>
</div>
<ScrollArea className="h-[min(52vh,420px)] rounded-lg border">
{events.length === 0 ? (
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
<RadioTower className="mb-3 h-8 w-8 text-muted-foreground" />
<p className="font-medium">
{t('bots.adapterEventEmptyTitle')}
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
{t('bots.adapterEventEmptyDescription', {
platform: platformName,
})}
</p>
</div>
) : (
<div className="space-y-3 p-3">
{events.map((event) => {
const preview = findEventPreview(event.eventData);
return (
<Card key={event.seqId} className="gap-0 py-0">
<CardContent className="p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">
{eventPatternLabel(event.eventType, t)}
</p>
<code className="mt-1 block truncate text-xs text-muted-foreground">
{event.eventType}
</code>
</div>
<time className="shrink-0 text-xs text-muted-foreground">
{new Date(
event.timestamp * 1000,
).toLocaleTimeString(i18n.language, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</time>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{preview ||
eventPatternDescription(event.eventType, t)}
</p>
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
>
{t('bots.adapterEventData')}
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
{JSON.stringify(event.eventData, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
})}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -703,6 +703,11 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
<EventBindingsEditor
form={form}
botId={initBotId}
adapterLabel={
adapterNameList.find(
(adapter) => adapter.value === currentAdapter,
)?.label ?? currentAdapter
}
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
agentOptions={agentNameList}
/>
@@ -120,6 +120,7 @@ import {
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
export const PIPELINE_DISCARD = '__discard__';
@@ -128,6 +129,7 @@ export const PIPELINE_DISCARD = '__discard__';
interface EventBindingsEditorProps {
form: UseFormReturn<any>;
botId?: string;
adapterLabel: string;
supportedEvents: string[];
agentOptions: Agent[];
}
@@ -1426,6 +1428,7 @@ function SortableBindingCard({
export default function EventBindingsEditor({
form,
botId,
adapterLabel,
supportedEvents,
agentOptions,
}: EventBindingsEditorProps) {
@@ -1797,6 +1800,7 @@ export default function EventBindingsEditor({
eventOptions={dryRunEventOptions}
agentOptions={agentOptions}
/>
<AdapterEventDebugDialog botId={botId} adapterLabel={adapterLabel} />
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
}
export interface BotLog {
images: [];
images: string[];
level: string;
message_session_id: string;
metadata?: Record<string, unknown> | null;
seq_id: number;
text: string;
timestamp: number;
+20
View File
@@ -451,6 +451,26 @@ const enUS = {
routeFallbackIgnored:
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
testRoute: 'Check route',
adapterEventDebugAction: 'Listen for platform events',
adapterEventDebugTitle: 'Platform event debugging',
adapterEventDebugDescription:
'Trigger an event in {{platform}}. It will appear here when the adapter receives it.',
adapterEventObserveOnly:
'This window only observes events. Incoming events still follow the current routes.',
adapterEventPreparing: 'Preparing',
adapterEventListening: 'Listening',
adapterEventListenerUnavailable: 'Listening interrupted',
adapterEventLoadFailed:
'Platform events could not be read. Make sure the bot is running, then try again.',
adapterEventReceivedCount: '{{count}} events received',
adapterEventClear: 'Clear',
adapterEventEmptyTitle: 'Waiting for a platform event',
adapterEventEmptyDescription:
'Send a message or trigger an event in {{platform}}.',
adapterEventData: 'View event data',
adapterEventNeedsSavedBot:
'Save the bot before listening for platform events.',
adapterEventCurrentPlatform: 'the current platform',
refreshRouteStatus: 'Refresh status',
routeStatusIdle: 'No run yet',
routeStatusRefreshFailed: 'Failed to refresh route status.',
+20
View File
@@ -458,6 +458,26 @@ const jaJP = {
routeFallbackIgnored:
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
testRoute: 'ルートを確認',
adapterEventDebugAction: 'プラットフォームイベントを監視',
adapterEventDebugTitle: 'プラットフォームイベントのデバッグ',
adapterEventDebugDescription:
'{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。',
adapterEventObserveOnly:
'この画面はイベントを監視するだけです。受信イベントは現在のルートで通常どおり処理されます。',
adapterEventPreparing: '準備中',
adapterEventListening: '監視中',
adapterEventListenerUnavailable: '監視が中断されました',
adapterEventLoadFailed:
'プラットフォームイベントを取得できません。ボットが起動していることを確認して、もう一度お試しください。',
adapterEventReceivedCount: '{{count}} 件のイベントを受信',
adapterEventClear: 'クリア',
adapterEventEmptyTitle: 'プラットフォームイベントを待機中',
adapterEventEmptyDescription:
'{{platform}} でメッセージを送るか、イベントを発生させてください。',
adapterEventData: 'イベントデータを表示',
adapterEventNeedsSavedBot:
'プラットフォームイベントを監視する前にボットを保存してください。',
adapterEventCurrentPlatform: '現在のプラットフォーム',
refreshRouteStatus: '状態を更新',
routeStatusIdle: '実行記録なし',
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
+18
View File
@@ -430,6 +430,24 @@ const zhHans = {
routeFallbackIgnored:
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
testRoute: '检查路由',
adapterEventDebugAction: '监听平台事件',
adapterEventDebugTitle: '平台事件调试',
adapterEventDebugDescription:
'在 {{platform}} 中触发事件,适配器收到后会显示在这里。',
adapterEventObserveOnly:
'此窗口只负责观察;收到的事件仍会按当前路由正常处理。',
adapterEventPreparing: '准备中',
adapterEventListening: '正在监听',
adapterEventListenerUnavailable: '监听已中断',
adapterEventLoadFailed:
'无法读取平台事件。请确认机器人正在运行,然后稍后重试。',
adapterEventReceivedCount: '已收到 {{count}} 个事件',
adapterEventClear: '清空',
adapterEventEmptyTitle: '等待平台事件',
adapterEventEmptyDescription: '请在 {{platform}} 中发送消息或触发事件。',
adapterEventData: '查看事件数据',
adapterEventNeedsSavedBot: '请先保存机器人后再监听平台事件。',
adapterEventCurrentPlatform: '当前平台',
refreshRouteStatus: '刷新状态',
routeStatusIdle: '暂无运行记录',
routeStatusRefreshFailed: '刷新路由状态失败。',
+67
View File
@@ -389,6 +389,44 @@ test.describe('bot advanced flows', () => {
}),
}),
);
let botLogPollCount = 0;
await page.route('**/api/v1/platform/bots/*/logs', (route) => {
botLogPollCount += 1;
const logs =
botLogPollCount === 1
? []
: [
{
seq_id: 7,
timestamp: Math.floor(Date.now() / 1000),
level: 'info',
text: 'Platform adapter received message.received',
images: [],
message_session_id: '',
metadata: {
kind: 'adapter_event_received',
event_type: 'message.received',
adapter: 'playwright-adapter',
bot_uuid: 'bot-1',
event_data: {
type: 'message.received',
chat_type: 'private',
chat_id: 'test-user',
message_chain: [{ type: 'Plain', text: 'adapter hello' }],
},
},
},
];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: { logs, total_count: logs.length },
}),
});
});
await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Route Status Bot');
@@ -492,6 +530,35 @@ test.describe('bot advanced flows', () => {
const dialogBox = await routeDialog.boundingBox();
expect(dialogBox).not.toBeNull();
expect(dialogBox!.height).toBeLessThan(500);
await routeDialog.getByRole('button', { name: 'Close' }).first().click();
await routingCard
.getByRole('button', { name: 'Listen for platform events' })
.click();
const adapterDialog = page.getByRole('dialog');
await expect(
adapterDialog.getByText('Platform event debugging', { exact: true }),
).toBeVisible();
await expect(adapterDialog).toContainText('Playwright Adapter');
await expect(adapterDialog).toContainText(
'This window only observes events. Incoming events still follow the current routes.',
);
await expect(
adapterDialog.getByText('Message received', { exact: true }),
).toBeVisible({ timeout: 5000 });
await expect(adapterDialog.getByText('message.received')).toBeVisible();
await expect(adapterDialog.getByText('adapter hello')).toBeVisible();
await adapterDialog
.getByRole('button', { name: 'View event data' })
.click();
await expect(
adapterDialog.getByText(/"chat_id": "test-user"/),
).toBeVisible();
await adapterDialog.getByRole('button', { name: 'Clear' }).click();
await expect(adapterDialog.getByText('0 events received')).toBeVisible();
await expect(
adapterDialog.getByText('Waiting for a platform event'),
).toBeVisible();
});
test('toggles bot enable/disable state', async ({ page }) => {