fix(bots): streamline detail layout and event listening

This commit is contained in:
RockChinQ
2026-09-10 15:29:52 +08:00
parent 15fa0f50e3
commit b7a04f7a24
16 changed files with 110 additions and 113 deletions
@@ -13,7 +13,7 @@ src/langbot/pkg/platform/adapters/aiocqhttp/
├── message_converter.py ├── message_converter.py
├── platform_api.py ├── platform_api.py
├── types.py ├── types.py
└── onebot.svg └── onebot.png
``` ```
The EBA adapter is registered as `aiocqhttp-omni`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`. The EBA adapter is registered as `aiocqhttp-omni`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`.
@@ -11,7 +11,7 @@ metadata:
en_US: OneBot v11 adapter for QQ-compatible protocol endpoints with event-driven orchestration support en_US: OneBot v11 adapter for QQ-compatible protocol endpoints with event-driven orchestration support
zh_Hans: OneBot v11 适配器,用于接入 QQ 兼容协议端,支持事件驱动编排 zh_Hans: OneBot v11 适配器,用于接入 QQ 兼容协议端,支持事件驱动编排
zh_Hant: OneBot v11 適配器,用於接入 QQ 相容協定端,支援事件驅動編排 zh_Hant: OneBot v11 適配器,用於接入 QQ 相容協定端,支援事件驅動編排
icon: onebot.svg icon: onebot.png
spec: spec:
categories: categories:
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -1,7 +0,0 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="20" fill="#16A34A"/>
<path d="M24 33C24 25.268 30.268 19 38 19H58C65.732 19 72 25.268 72 33V51C72 58.732 65.732 65 58 65H41.5L29 77V64.059C26.024 61.514 24 57.729 24 51V33Z" fill="white"/>
<circle cx="39" cy="42" r="5" fill="#16A34A"/>
<circle cx="57" cy="42" r="5" fill="#16A34A"/>
<path d="M39 53C44.5 57 51.5 57 57 53" stroke="#16A34A" stroke-width="5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 527 B

+29 -53
View File
@@ -4,13 +4,6 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -36,6 +29,7 @@ import { Bot } from '@/app/infra/entities/api';
import EntityBasicInfoDialog, { import EntityBasicInfoDialog, {
EntityBasicInfoValues, EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog'; } from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import AdapterEventDebugDialog from './components/bot-form/AdapterEventDebugDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton'; import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function BotDetailContent({ id }: { id: string }) { export default function BotDetailContent({ id }: { id: string }) {
@@ -61,6 +55,7 @@ export default function BotDetailContent({ id }: { id: string }) {
}, [id, isCreateMode, bots, setDetailEntityName, t]); }, [id, isCreateMode, bots, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config'); const [activeTab, setActiveTab] = useState('config');
const [adapterLabel, setAdapterLabel] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false); const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [bot, setBot] = useState<Bot | null>(null); const [bot, setBot] = useState<Bot | null>(null);
@@ -191,7 +186,7 @@ export default function BotDetailContent({ id }: { id: string }) {
<> <>
<div className="flex h-full min-w-0 flex-col"> <div className="flex h-full min-w-0 flex-col">
{/* Sticky Header: title + enable switch + save button */} {/* Sticky Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0"> <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
<div className="flex min-w-0 items-center gap-4"> <div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<h1 className="truncate text-xl font-semibold"> <h1 className="truncate text-xl font-semibold">
@@ -219,14 +214,29 @@ export default function BotDetailContent({ id }: { id: string }) {
)} )}
</div> </div>
{canManage && ( {canManage && (
<Button <div className="flex shrink-0 items-center gap-2">
type="submit" <AdapterEventDebugDialog
form="bot-form" key={id}
disabled={!formDirty} botId={id}
className={activeTab !== 'config' ? 'invisible' : ''} adapterLabel={adapterLabel}
> />
{t('common.save')} <Button
</Button> type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
</div>
)} )}
</div> </div>
@@ -286,9 +296,9 @@ export default function BotDetailContent({ id }: { id: string }) {
{/* Tab: Configuration */} {/* Tab: Configuration */}
<TabsContent <TabsContent
value="config" value="config"
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden" className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden lg:overflow-hidden"
> >
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-6 pb-8"> <div className="min-h-0 min-w-0 pb-4 lg:h-full lg:pb-0">
<fieldset className="contents" disabled={!canManage}> <fieldset className="contents" disabled={!canManage}>
<BotForm <BotForm
ref={botFormRef} ref={botFormRef}
@@ -296,43 +306,9 @@ export default function BotDetailContent({ id }: { id: string }) {
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated} onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty} onDirtyChange={setFormDirty}
onAdapterLabelChange={setAdapterLabel}
/> />
</fieldset> </fieldset>
{/* Card: Danger Zone */}
{canManage && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('bots.dangerZone')}
</CardTitle>
<CardDescription>
{t('bots.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
)}
</div> </div>
</TabsContent> </TabsContent>
@@ -192,12 +192,11 @@ export default function AdapterEventDebugDialog({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm"
disabled={!botId} disabled={!botId}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined} title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
> >
<RadioTower className="mr-1 h-4 w-4" /> <RadioTower className="size-4" />
{t('bots.adapterEventDebugAction')} {t('bots.adapterEventDebugAction')}
</Button> </Button>
@@ -21,8 +21,8 @@ import { systemInfo } from '@/app/infra/http';
import { Agent, Bot } from '@/app/infra/entities/api'; import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'; import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import EventBindingsEditor from './EventBindingsEditor'; import EventBindingsEditor from './EventBindingsEditor';
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -101,10 +101,17 @@ interface BotFormProps {
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void; onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onNewBotCreated: (botId: string) => void; onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onAdapterLabelChange?: (label: string) => void;
} }
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm( const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange }, {
initBotId,
onFormSubmit,
onNewBotCreated,
onDirtyChange,
onAdapterLabelChange,
},
ref, ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -153,6 +160,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
// Watch adapter and adapter_config for filtering // Watch adapter and adapter_config for filtering
const currentAdapter = form.watch('adapter'); const currentAdapter = form.watch('adapter');
const adapterLabel =
adapterNameList.find((adapter) => adapter.value === currentAdapter)
?.label ?? '';
useEffect(() => {
onAdapterLabelChange?.(adapterLabel);
}, [adapterLabel, onAdapterLabelChange]);
const currentAdapterConfig = form.watch('adapter_config'); const currentAdapterConfig = form.watch('adapter_config');
// Group adapters by category for the Select dropdown. Legacy adapters are // Group adapters by category for the Select dropdown. Legacy adapters are
@@ -440,10 +453,15 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
id="bot-form" id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)} onSubmit={form.handleSubmit(onDynamicFormSubmit)}
aria-busy={isLoading} aria-busy={isLoading}
className="w-full min-w-0 max-w-full" className={cn('w-full min-w-0 max-w-full', initBotId && 'lg:h-full')}
> >
<fieldset <fieldset
className="w-full min-w-0 max-w-full space-y-6" className={cn(
'w-full min-w-0 max-w-full',
initBotId
? 'grid gap-4 lg:h-full lg:min-h-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] lg:grid-rows-[minmax(0,1fr)]'
: 'space-y-6',
)}
disabled={isLoading} disabled={isLoading}
> >
{!initBotId && ( {!initBotId && (
@@ -489,14 +507,24 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
)} )}
{/* Card 2: Adapter Configuration */} {/* Card 2: Adapter Configuration */}
<Card> <Card
className={cn(
'min-w-0',
initBotId && 'lg:min-h-0 lg:overflow-hidden',
)}
>
<CardHeader> <CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle> <CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription> <CardDescription>
{t('bots.adapterConfigDescription')} {t('bots.adapterConfigDescription')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent
className={cn(
'min-w-0 space-y-4',
initBotId && 'lg:min-h-0 lg:flex-1 lg:overflow-y-auto',
)}
>
<FormField <FormField
control={form.control} control={form.control}
name="adapter" name="adapter"
@@ -507,7 +535,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
field.onChange(value); field.onChange(value);
@@ -515,7 +543,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
}} }}
value={field.value} value={field.value}
> >
<SelectTrigger className="w-[240px] overflow-hidden"> <SelectTrigger className="w-full min-w-0 overflow-hidden sm:w-[240px]">
{field.value ? ( {field.value ? (
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<img <img
@@ -693,40 +721,29 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
}} }}
/> />
)} )}
{currentAdapter && initBotId && (
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">
{t('bots.adapterConfigurationTest')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('bots.adapterConfigurationTestDescription')}
</p>
</div>
<AdapterEventDebugDialog
botId={initBotId}
adapterLabel={
adapterNameList.find(
(adapter) => adapter.value === currentAdapter,
)?.label ?? currentAdapter
}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Card 3: Event Routing */} {/* Card 3: Event Routing */}
{currentAdapter && ( {currentAdapter && (
<Card> <Card
className={cn(
'min-w-0',
initBotId && 'lg:min-h-0 lg:overflow-hidden',
)}
>
<CardHeader> <CardHeader>
<CardTitle>{t('bots.eventRouting')}</CardTitle> <CardTitle>{t('bots.eventRouting')}</CardTitle>
<CardDescription> <CardDescription>
{t('bots.eventRoutingDescription')} {t('bots.eventRoutingDescription')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent
className={cn(
'min-w-0',
initBotId && 'lg:min-h-0 lg:flex-1 lg:overflow-y-auto',
)}
>
<EventBindingsEditor <EventBindingsEditor
form={form} form={form}
botId={initBotId} botId={initBotId}
+1 -1
View File
@@ -450,7 +450,7 @@ const enUS = {
routeFallbackIgnored: routeFallbackIgnored:
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.', 'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
testRoute: 'Check route', testRoute: 'Check route',
adapterEventDebugAction: 'Listen for platform events', adapterEventDebugAction: 'Test listener',
adapterEventDebugTitle: 'Platform event debugging', adapterEventDebugTitle: 'Platform event debugging',
adapterEventDebugDescription: adapterEventDebugDescription:
'Trigger an event in {{platform}}. It will appear here when the adapter receives it.', 'Trigger an event in {{platform}}. It will appear here when the adapter receives it.',
+1
View File
@@ -348,6 +348,7 @@ const esES = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
adapterEventDebugAction: 'Probar escucha',
title: 'Bots', title: 'Bots',
description: description:
'Crea y gestiona Bots, que son los puntos de entrada para que LangBot se conecte con diversas plataformas', 'Crea y gestiona Bots, que son los puntos de entrada para que LangBot se conecte con diversas plataformas',
+1 -1
View File
@@ -458,7 +458,7 @@ const jaJP = {
routeFallbackIgnored: routeFallbackIgnored:
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。', 'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
testRoute: 'ルートを確認', testRoute: 'ルートを確認',
adapterEventDebugAction: 'プラットフォームイベント監視', adapterEventDebugAction: 'イベント監視テスト',
adapterEventDebugTitle: 'プラットフォームイベントのデバッグ', adapterEventDebugTitle: 'プラットフォームイベントのデバッグ',
adapterEventDebugDescription: adapterEventDebugDescription:
'{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。', '{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。',
+1
View File
@@ -346,6 +346,7 @@ const ruRU = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
adapterEventDebugAction: 'Тест прослушивания',
title: 'Боты', title: 'Боты',
description: description:
'Создание и управление ботами — точками входа LangBot для подключения к различным платформам', 'Создание и управление ботами — точками входа LangBot для подключения к различным платформам',
+1
View File
@@ -333,6 +333,7 @@ const thTH = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
adapterEventDebugAction: 'ทดสอบการรับเหตุการณ์',
title: 'บอท', title: 'บอท',
description: description:
'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ', 'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ',
+1
View File
@@ -342,6 +342,7 @@ const viVN = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
adapterEventDebugAction: 'Kiểm tra lắng nghe',
title: 'Bot', title: 'Bot',
description: description:
'Tạo và quản lý Bot, là điểm kết nối của LangBot với các nền tảng khác nhau', 'Tạo và quản lý Bot, là điểm kết nối của LangBot với các nền tảng khác nhau',
+1 -1
View File
@@ -428,7 +428,7 @@ const zhHans = {
routeFallbackIgnored: routeFallbackIgnored:
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。', '未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
testRoute: '检查路由', testRoute: '检查路由',
adapterEventDebugAction: '监听平台事件', adapterEventDebugAction: '测试监听',
adapterEventDebugTitle: '平台事件调试', adapterEventDebugTitle: '平台事件调试',
adapterEventDebugDescription: adapterEventDebugDescription:
'在 {{platform}} 中触发事件,适配器收到后会显示在这里。', '在 {{platform}} 中触发事件,适配器收到后会显示在这里。',
+1
View File
@@ -322,6 +322,7 @@ const zhHant = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
adapterEventDebugAction: '測試監聽',
title: '機器人', title: '機器人',
description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口', description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口',
createBot: '建立機器人', createBot: '建立機器人',
+22 -15
View File
@@ -501,16 +501,15 @@ test.describe('bot advanced flows', () => {
const adapterCard = page.locator('[data-slot="card"]').filter({ const adapterCard = page.locator('[data-slot="card"]').filter({
has: page.getByText('Adapter Configuration', { exact: true }), has: page.getByText('Adapter Configuration', { exact: true }),
}); });
const dangerCard = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('Danger Zone', { exact: true }) });
const routingBox = await routingCard.boundingBox(); const routingBox = await routingCard.boundingBox();
const dangerBox = await dangerCard.boundingBox(); const adapterBox = await adapterCard.boundingBox();
expect(routingBox).not.toBeNull(); expect(routingBox).not.toBeNull();
expect(dangerBox).not.toBeNull(); expect(adapterBox).not.toBeNull();
expect( expect(routingBox!.x).toBeGreaterThan(adapterBox!.x + adapterBox!.width);
dangerBox!.y - (routingBox!.y + routingBox!.height), expect(Math.abs(routingBox!.y - adapterBox!.y)).toBeLessThan(2);
).toBeGreaterThanOrEqual(20); await expect(
page.getByRole('button', { name: /^Delete$/ }),
).toBeInViewport();
await routingCard.getByRole('button', { name: 'View all' }).click(); await routingCard.getByRole('button', { name: 'View all' }).click();
await expect( await expect(
@@ -588,15 +587,22 @@ test.describe('bot advanced flows', () => {
expect(dialogBox!.height).toBeLessThan(500); expect(dialogBox!.height).toBeLessThan(500);
await routeDialog.getByRole('button', { name: 'Close' }).first().click(); await routeDialog.getByRole('button', { name: 'Close' }).first().click();
await expect(
routingCard.getByRole('button', { name: 'Listen for platform events' }),
).toHaveCount(0);
await expect( await expect(
adapterCard.getByText('Test adapter configuration'), adapterCard.getByText('Test adapter configuration'),
).toBeVisible(); ).toHaveCount(0);
await adapterCard const listenButton = page.getByRole('button', {
.getByRole('button', { name: 'Listen for platform events' }) name: 'Test listener',
.click(); exact: true,
});
const listenBox = await listenButton.boundingBox();
const saveBox = await page
.getByRole('button', { name: /^Save$/ })
.boundingBox();
expect(listenBox).not.toBeNull();
expect(saveBox).not.toBeNull();
expect(listenBox!.x + listenBox!.width).toBeLessThan(saveBox!.x);
expect(Math.abs(listenBox!.y - saveBox!.y)).toBeLessThan(2);
await listenButton.click();
const adapterDialog = page.getByRole('dialog'); const adapterDialog = page.getByRole('dialog');
await expect( await expect(
adapterDialog.getByText('Platform event debugging', { exact: true }), adapterDialog.getByText('Platform event debugging', { exact: true }),
@@ -1338,6 +1344,7 @@ test.describe('cross-resource flows', () => {
const firstHandle = page.getByRole('button', { name: 'Drag route 1' }); const firstHandle = page.getByRole('button', { name: 'Drag route 1' });
const secondCard = routeCards.nth(1); const secondCard = routeCards.nth(1);
await firstHandle.scrollIntoViewIfNeeded();
const handleBox = await firstHandle.boundingBox(); const handleBox = await firstHandle.boundingBox();
const targetBox = await secondCard.boundingBox(); const targetBox = await secondCard.boundingBox();
expect(handleBox).not.toBeNull(); expect(handleBox).not.toBeNull();