diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index a68a4a7..c055c46 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; +import { ChevronDown } from 'lucide-react'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; import { toast } from '../ui/sonner'; import { cn } from '@/lib/utils'; import { api } from '../../api'; @@ -15,21 +17,12 @@ const BotCodeEditor = lazy(() => const TYPE_LABELS: Record = { mqtt_private: 'Private MQTT', mqtt_community: 'Community MQTT', - bot: 'Bot', + bot: 'Python Bot', webhook: 'Webhook', apprise: 'Apprise', sqs: 'Amazon SQS', }; -const LIST_TYPE_OPTIONS = [ - { value: 'mqtt_private', label: 'Private MQTT' }, - { value: 'mqtt_community', label: 'Community MQTT' }, - { value: 'bot', label: 'Bot' }, - { value: 'webhook', label: 'Webhook' }, - { value: 'apprise', label: 'Apprise' }, - { value: 'sqs', label: 'Amazon SQS' }, -]; - const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets'; const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net'; const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net'; @@ -42,30 +35,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp'; const DEFAULT_MESHRANK_AUTH_MODE = 'none'; const DEFAULT_MESHRANK_IATA = 'XYZ'; -const CREATE_TYPE_OPTIONS = [ - { value: 'mqtt_private', label: 'Private MQTT' }, - { value: 'mqtt_community_meshrank', label: 'MeshRank' }, - { value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' }, - { value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' }, - { value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' }, - { value: 'bot', label: 'Bot' }, - { value: 'webhook', label: 'Webhook' }, - { value: 'apprise', label: 'Apprise' }, - { value: 'sqs', label: 'Amazon SQS' }, -] as const; - -type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value']; - -type DraftRecipe = { - savedType: string; - detailLabel: string; - defaultName: string; - defaults: { - config: Record; - scope: Record; - }; -}; - function createCommunityConfigDefaults( overrides: Partial> = {} ): Record { @@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None: return "[BOT] Plong!" return None`; -const DRAFT_RECIPES: Record = { - mqtt_private: { +type DraftType = + | 'mqtt_private' + | 'mqtt_community' + | 'mqtt_community_meshrank' + | 'mqtt_community_letsmesh_us' + | 'mqtt_community_letsmesh_eu' + | 'webhook' + | 'apprise' + | 'sqs' + | 'bot'; + +type CreateIntegrationDefinition = { + value: DraftType; + savedType: string; + label: string; + section: string; + description: string; + defaultName: string; + nameMode: 'counted' | 'fixed'; + defaults: { + config: Record; + scope: Record; + }; +}; + +const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ + { + value: 'mqtt_private', savedType: 'mqtt_private', - detailLabel: 'Private MQTT', + label: 'Private MQTT', + section: 'Bulk Forwarding', + description: + 'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.', defaultName: 'Private MQTT', + nameMode: 'counted', defaults: { config: { broker_host: '', @@ -140,10 +139,29 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'all', raw_packets: 'all' }, }, }, - mqtt_community_meshrank: { + { + value: 'mqtt_community', savedType: 'mqtt_community', - detailLabel: 'MeshRank', + label: 'Community MQTT/meshcoretomqtt', + section: 'Community MQTT', + description: + 'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.', + defaultName: 'Community MQTT', + nameMode: 'counted', + defaults: { + config: createCommunityConfigDefaults(), + scope: { messages: 'none', raw_packets: 'all' }, + }, + }, + { + value: 'mqtt_community_meshrank', + savedType: 'mqtt_community', + label: 'MeshRank', + section: 'Community MQTT', + description: + 'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'MeshRank', + nameMode: 'fixed', defaults: { config: createCommunityConfigDefaults({ broker_host: DEFAULT_MESHRANK_BROKER_HOST, @@ -158,10 +176,15 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'none', raw_packets: 'all' }, }, }, - mqtt_community_letsmesh_us: { + { + value: 'mqtt_community_letsmesh_us', savedType: 'mqtt_community', - detailLabel: 'LetsMesh (US)', + label: 'LetsMesh (US)', + section: 'Community MQTT', + description: + 'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'LetsMesh (US)', + nameMode: 'fixed', defaults: { config: createCommunityConfigDefaults({ broker_host: DEFAULT_COMMUNITY_BROKER_HOST, @@ -170,10 +193,15 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'none', raw_packets: 'all' }, }, }, - mqtt_community_letsmesh_eu: { + { + value: 'mqtt_community_letsmesh_eu', savedType: 'mqtt_community', - detailLabel: 'LetsMesh (EU)', + label: 'LetsMesh (EU)', + section: 'Community MQTT', + description: + 'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'LetsMesh (EU)', + nameMode: 'fixed', defaults: { config: createCommunityConfigDefaults({ broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU, @@ -182,30 +210,15 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'none', raw_packets: 'all' }, }, }, - mqtt_community: { - savedType: 'mqtt_community', - detailLabel: 'Community MQTT/meshcoretomqtt', - defaultName: 'Community MQTT', - defaults: { - config: createCommunityConfigDefaults(), - scope: { messages: 'none', raw_packets: 'all' }, - }, - }, - bot: { - savedType: 'bot', - detailLabel: 'Bot', - defaultName: 'Bot', - defaults: { - config: { - code: DEFAULT_BOT_CODE, - }, - scope: { messages: 'all', raw_packets: 'none' }, - }, - }, - webhook: { + { + value: 'webhook', savedType: 'webhook', - detailLabel: 'Webhook', + label: 'Webhook', + section: 'Automation', + description: + 'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.', defaultName: 'Webhook', + nameMode: 'counted', defaults: { config: { url: '', @@ -217,10 +230,15 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'all', raw_packets: 'none' }, }, }, - apprise: { + { + value: 'apprise', savedType: 'apprise', - detailLabel: 'Apprise', + label: 'Apprise', + section: 'Automation', + description: + 'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.', defaultName: 'Apprise', + nameMode: 'counted', defaults: { config: { urls: '', @@ -230,10 +248,14 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'all', raw_packets: 'none' }, }, }, - sqs: { + { + value: 'sqs', savedType: 'sqs', - detailLabel: 'Amazon SQS', + label: 'Amazon SQS', + section: 'Bulk Forwarding', + description: 'Send full or scope-customized raw or decrypted packets to an SQS', defaultName: 'Amazon SQS', + nameMode: 'counted', defaults: { config: { queue_url: '', @@ -246,15 +268,41 @@ const DRAFT_RECIPES: Record = { scope: { messages: 'all', raw_packets: 'none' }, }, }, -}; + { + value: 'bot', + savedType: 'bot', + label: 'Python Bot', + section: 'Automation', + description: + 'A simple, Python-based interface for basic bots that can respond to DM and channel messages.', + defaultName: 'Bot', + nameMode: 'counted', + defaults: { + config: { + code: DEFAULT_BOT_CODE, + }, + scope: { messages: 'all', raw_packets: 'none' }, + }, + }, +]; + +const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries( + CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition]) +) as Record; function isDraftType(value: string): value is DraftType { - return value in DRAFT_RECIPES; + return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE; +} + +function getCreateIntegrationDefinition(draftType: DraftType) { + return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType]; } function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) { - const recipe = DRAFT_RECIPES[draftType]; - return name || getDefaultIntegrationName(recipe.savedType, configs); + const definition = getCreateIntegrationDefinition(draftType); + if (name) return name; + if (definition.nameMode === 'fixed') return definition.defaultName; + return getDefaultIntegrationName(definition.savedType, configs); } function normalizeDraftConfig(draftType: DraftType, config: Record) { @@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record) { - if (draftType.startsWith('mqtt_community_')) { + if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') { return { messages: 'none', raw_packets: 'all' }; } return scope; } function cloneDraftDefaults(draftType: DraftType) { - const recipe = DRAFT_RECIPES[draftType]; + const recipe = getCreateIntegrationDefinition(draftType); return { config: structuredClone(recipe.defaults.config), scope: structuredClone(recipe.defaults.scope), }; } +function CreateIntegrationDialog({ + open, + options, + selectedType, + onOpenChange, + onSelect, + onCreate, +}: { + open: boolean; + options: readonly CreateIntegrationDefinition[]; + selectedType: DraftType | null; + onOpenChange: (open: boolean) => void; + onSelect: (type: DraftType) => void; + onCreate: () => void; +}) { + const selectedOption = + options.find((option) => option.value === selectedType) ?? options[0] ?? null; + const listRef = useRef(null); + const [showScrollHint, setShowScrollHint] = useState(false); + + const updateScrollHint = useCallback(() => { + const container = listRef.current; + if (!container) { + setShowScrollHint(false); + return; + } + setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8); + }, []); + + useEffect(() => { + if (!open) return; + const frame = window.requestAnimationFrame(updateScrollHint); + window.addEventListener('resize', updateScrollHint); + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('resize', updateScrollHint); + }; + }, [open, options, updateScrollHint]); + + const sectionedOptions = [...new Set(options.map((o) => o.section))] + .map((section) => ({ + section, + options: options.filter((option) => option.section === section), + })) + .filter((group) => group.options.length > 0); + + return ( + + + + Create Integration + + +
+
+
+
+ {sectionedOptions.map((group) => ( +
+
+ {group.section} +
+ {group.options.map((option) => { + const selected = option.value === selectedOption?.value; + return ( + + ); + })} +
+ ))} +
+
+ + {showScrollHint && ( +
+
+
+
+ )} +
+ +
+ {selectedOption ? ( + <> +
+
+ {selectedOption.section} +
+

{selectedOption.label}

+
+ +

+ {selectedOption.description} +

+ + ) : ( +
+ No integration types are currently available. +
+ )} +
+
+ + + + + +
+
+ ); +} + function getDetailTypeLabel(detailType: string) { - if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel; + if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label; return TYPE_LABELS[detailType] || detailType; } @@ -1499,9 +1685,9 @@ export function SettingsFanoutSection({ const [editName, setEditName] = useState(''); const [inlineEditingId, setInlineEditingId] = useState(null); const [inlineEditName, setInlineEditName] = useState(''); - const [addMenuOpen, setAddMenuOpen] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [selectedCreateType, setSelectedCreateType] = useState(null); const [busy, setBusy] = useState(false); - const addMenuRef = useRef(null); const loadConfigs = useCallback(async () => { try { @@ -1516,18 +1702,28 @@ export function SettingsFanoutSection({ loadConfigs(); }, [loadConfigs]); + const availableCreateOptions = useMemo( + () => + CREATE_INTEGRATION_DEFINITIONS.filter( + (definition) => definition.savedType !== 'bot' || !health?.bots_disabled + ), + [health?.bots_disabled] + ); + useEffect(() => { - if (!addMenuOpen) return; - - const handlePointerDown = (event: MouseEvent) => { - if (!addMenuRef.current?.contains(event.target as Node)) { - setAddMenuOpen(false); - } - }; - - document.addEventListener('mousedown', handlePointerDown); - return () => document.removeEventListener('mousedown', handlePointerDown); - }, [addMenuOpen]); + if (!createDialogOpen) return; + if (availableCreateOptions.length === 0) { + setSelectedCreateType(null); + return; + } + if ( + selectedCreateType && + availableCreateOptions.some((option) => option.value === selectedCreateType) + ) { + return; + } + setSelectedCreateType(availableCreateOptions[0].value); + }, [createDialogOpen, availableCreateOptions, selectedCreateType]); const handleToggleEnabled = async (cfg: FanoutConfig) => { try { @@ -1541,7 +1737,7 @@ export function SettingsFanoutSection({ }; const handleEdit = (cfg: FanoutConfig) => { - setAddMenuOpen(false); + setCreateDialogOpen(false); setInlineEditingId(null); setInlineEditName(''); setDraftType(null); @@ -1552,7 +1748,7 @@ export function SettingsFanoutSection({ }; const handleStartInlineEdit = (cfg: FanoutConfig) => { - setAddMenuOpen(false); + setCreateDialogOpen(false); setInlineEditingId(cfg.id); setInlineEditName(cfg.name); }; @@ -1611,7 +1807,7 @@ export function SettingsFanoutSection({ setBusy(true); try { if (currentDraftType) { - const recipe = DRAFT_RECIPES[currentDraftType]; + const recipe = getCreateIntegrationDefinition(currentDraftType); await api.createFanoutConfig({ type: recipe.savedType, name: normalizeDraftName(currentDraftType, editName.trim(), configs), @@ -1663,18 +1859,16 @@ export function SettingsFanoutSection({ } }; - const handleAddCreate = async (type: string) => { - if (!isDraftType(type)) return; + const handleAddCreate = (type: DraftType) => { + const definition = getCreateIntegrationDefinition(type); const defaults = cloneDraftDefaults(type); - setAddMenuOpen(false); + setCreateDialogOpen(false); setEditingId(null); setDraftType(type); setEditName( - type === 'mqtt_community_meshrank' || - type === 'mqtt_community_letsmesh_us' || - type === 'mqtt_community_letsmesh_eu' - ? DRAFT_RECIPES[type].defaultName - : getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs) + definition.nameMode === 'fixed' + ? definition.defaultName + : getDefaultIntegrationName(definition.savedType, configs) ); setEditConfig(defaults.config); setEditScope(defaults.scope); @@ -1683,13 +1877,15 @@ export function SettingsFanoutSection({ const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null; const detailType = draftType ?? editingConfig?.type ?? null; const isDraft = draftType !== null; - const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({ - type: opt.value, - label: opt.label, - configs: configs - .filter((cfg) => cfg.type === opt.value) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })), - })).filter((group) => group.configs.length > 0); + const configGroups = Object.entries(TYPE_LABELS) + .map(([type, label]) => ({ + type, + label, + configs: configs + .filter((cfg) => cfg.type === type) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })), + })) + .filter((group) => group.configs.length > 0); // Detail view if (detailType) { @@ -1823,37 +2019,22 @@ export function SettingsFanoutSection({ )} -
- - {addMenuOpen && ( -
- {CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map( - (opt) => ( - - ) - )} -
- )} -
+ + + { + if (selectedCreateType) { + handleAddCreate(selectedCreateType); + } + }} + /> {configGroups.length > 0 && (
diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index 6a54d5f..4cd447b 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -67,6 +67,29 @@ function renderSectionWithRefresh( ); } +function startsWithAccessibleName(name: string) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escaped}(?:\\s|$)`); +} + +async function openCreateIntegrationDialog() { + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); + return screen.findByRole('dialog', { name: 'Create Integration' }); +} + +function selectCreateIntegration(name: string) { + const dialog = screen.getByRole('dialog', { name: 'Create Integration' }); + fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) })); +} + +function confirmCreateIntegration() { + const dialog = screen.getByRole('dialog', { name: 'Create Integration' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create' })); +} + beforeEach(() => { vi.clearAllMocks(); vi.spyOn(window, 'confirm').mockReturnValue(true); @@ -76,35 +99,64 @@ beforeEach(() => { }); describe('SettingsFanoutSection', () => { - it('shows add integration menu with all integration types', async () => { + it('shows add integration dialog with all integration types', async () => { renderSection(); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); - }); + const dialog = await openCreateIntegrationDialog(); - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - - expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument(); + const optionButtons = within(dialog) + .getAllByRole('button') + .filter((button) => button.hasAttribute('aria-pressed')); + expect(optionButtons).toHaveLength(9); + expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument(); expect( - screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }) + within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') }) ).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { + name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'), + }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') }) + ).toBeInTheDocument(); + expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument(); + + const genericCommunityIndex = optionButtons.findIndex((button) => + button.textContent?.startsWith('Community MQTT/meshcoretomqtt') + ); + const meshRankIndex = optionButtons.findIndex((button) => + button.textContent?.startsWith('MeshRank') + ); + expect(genericCommunityIndex).toBeGreaterThan(-1); + expect(meshRankIndex).toBeGreaterThan(-1); + expect(genericCommunityIndex).toBeLessThan(meshRankIndex); }); - it('shows bot option in add integration menu when bots are enabled', async () => { + it('shows bot option in add integration dialog when bots are enabled', async () => { renderSection(); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument(); + const dialog = await openCreateIntegrationDialog(); + expect( + within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') }) + ).toBeInTheDocument(); }); it('shows bots disabled banner when bots_disabled', async () => { @@ -123,14 +175,12 @@ describe('SettingsFanoutSection', () => { }); }); - it('hides bot option from add integration menu when bots_disabled', async () => { + it('hides bot option from add integration dialog when bots_disabled', async () => { renderSection({ health: { ...baseHealth, bots_disabled: true } }); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument(); + const dialog = await openCreateIntegrationDialog(); + expect( + within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') }) + ).not.toBeInTheDocument(); }); it('lists existing configs after load', async () => { @@ -305,12 +355,9 @@ describe('SettingsFanoutSection', () => { it('navigates to create view when clicking add button', async () => { renderSection(); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('Webhook'); + confirmCreateIntegration(); await waitFor(() => { expect(screen.getByText('← Back to list')).toBeInTheDocument(); @@ -324,12 +371,9 @@ describe('SettingsFanoutSection', () => { it('new SQS draft shows queue url fields and sensible defaults', async () => { renderSection(); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('Amazon SQS'); + confirmCreateIntegration(); await waitFor(() => { expect(screen.getByText('← Back to list')).toBeInTheDocument(); @@ -341,12 +385,9 @@ describe('SettingsFanoutSection', () => { it('backing out of a new draft does not create an integration', async () => { renderSection(); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('Webhook'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); fireEvent.click(screen.getByText('← Back to list')); @@ -420,12 +461,9 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]); renderSection(); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('Webhook'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' })); @@ -453,8 +491,9 @@ describe('SettingsFanoutSection', () => { renderSection(); await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('Webhook'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3')); }); @@ -656,21 +695,21 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]); renderSection(); - await waitFor(() => - expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument() - ); - expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument(); + const group = await screen.findByRole('group', { name: 'Integration Community Feed' }); + expect( + within(group).getByText( + (_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443' + ) + ).toBeInTheDocument(); + expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument(); expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument(); }); it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => { renderSection(); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('MeshRank'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); @@ -707,12 +746,9 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]); renderSection(); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('MeshRank'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); fireEvent.change(screen.getByLabelText('Packet Topic Template'), { @@ -774,12 +810,9 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]); renderSection(); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('LetsMesh (US)'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)'); @@ -842,12 +875,9 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]); renderSection(); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('LetsMesh (EU)'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } }); @@ -880,12 +910,9 @@ describe('SettingsFanoutSection', () => { it('generic Community MQTT entry still opens the full editor', async () => { renderSection(); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument() - ); - - fireEvent.click(screen.getByRole('button', { name: 'Add Integration' })); - fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })); + await openCreateIntegrationDialog(); + selectCreateIntegration('Community MQTT/meshcoretomqtt'); + confirmCreateIntegration(); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); @@ -909,9 +936,12 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]); renderSection(); - await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument()); + const group = await screen.findByRole('group', { name: 'Integration Private Broker' }); expect( - screen.getByText('meshcore/dm:, meshcore/gm:, meshcore/raw/...') + within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883') + ).toBeInTheDocument(); + expect( + within(group).getByText('meshcore/dm:, meshcore/gm:, meshcore/raw/...') ).toBeInTheDocument(); }); @@ -929,7 +959,8 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValue([config]); renderSection(); - await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument()); + const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' }); + expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument(); }); it('apprise list shows compact target summary', async () => { @@ -950,9 +981,10 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValue([config]); renderSection(); - await waitFor(() => - expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument() - ); + const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' }); + expect( + within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/) + ).toBeInTheDocument(); }); it('sqs list shows queue url summary', async () => { @@ -972,11 +1004,10 @@ describe('SettingsFanoutSection', () => { mockedApi.getFanoutConfigs.mockResolvedValue([config]); renderSection(); - await waitFor(() => - expect( - screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events') - ).toBeInTheDocument() - ); + const group = await screen.findByRole('group', { name: 'Integration Queue Feed' }); + expect( + within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events') + ).toBeInTheDocument(); }); it('groups integrations by type and sorts entries alphabetically within each group', async () => { diff --git a/tests/e2e/helpers/fanout.ts b/tests/e2e/helpers/fanout.ts index d8e9946..7b8a067 100644 --- a/tests/e2e/helpers/fanout.ts +++ b/tests/e2e/helpers/fanout.ts @@ -1,6 +1,10 @@ import type { Locator, Page } from '@playwright/test'; import http from 'http'; +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export function createCaptureServer(urlFactory: (port: number) => string) { const requests: { body: string; headers: http.IncomingHttpHeaders }[] = []; const server = http.createServer((req, res) => { @@ -38,6 +42,15 @@ export async function openFanoutSettings(page: Page): Promise { await page.getByRole('button', { name: /MQTT.*Automation/ }).click(); } +export async function startIntegrationDraft(page: Page, integrationName: string): Promise { + await page.getByRole('button', { name: 'Add Integration' }).click(); + const dialog = page.getByRole('dialog', { name: 'Create Integration' }); + await dialog + .getByRole('button', { name: new RegExp(`^${escapeRegex(integrationName)}(?:\\s|$)`) }) + .click(); + await dialog.getByRole('button', { name: 'Create' }).click(); +} + export function fanoutHeader(page: Page, name: string): Locator { const nameButton = page.getByRole('button', { name, exact: true }); return page diff --git a/tests/e2e/specs/apprise.spec.ts b/tests/e2e/specs/apprise.spec.ts index b2ceeb8..3da6a09 100644 --- a/tests/e2e/specs/apprise.spec.ts +++ b/tests/e2e/specs/apprise.spec.ts @@ -4,7 +4,12 @@ import { deleteFanoutConfig, getFanoutConfigs, } from '../helpers/api'; -import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout'; +import { + createCaptureServer, + fanoutHeader, + openFanoutSettings, + startIntegrationDraft, +} from '../helpers/fanout'; test.describe('Apprise integration settings', () => { let createdAppriseId: string | null = null; @@ -35,9 +40,7 @@ test.describe('Apprise integration settings', () => { await openFanoutSettings(page); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); - // Open add menu and pick Apprise - await page.getByRole('button', { name: 'Add Integration' }).click(); - await page.getByRole('menuitem', { name: 'Apprise' }).click(); + await startIntegrationDraft(page, 'Apprise'); // Should navigate to the detail/edit view with a numbered default name await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/); diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts index 77090df..29d32f8 100644 --- a/tests/e2e/specs/bot.spec.ts +++ b/tests/e2e/specs/bot.spec.ts @@ -1,9 +1,10 @@ import { test, expect } from '@playwright/test'; import { ensureFlightlessChannel, - createFanoutConfig, deleteFanoutConfig, + getFanoutConfigs, } from '../helpers/api'; +import { openFanoutSettings, startIntegrationDraft } from '../helpers/fanout'; const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): if channel_name == "#flightless" and "!e2etest" in message_text.lower(): @@ -28,32 +29,35 @@ test.describe('Bot functionality', () => { } }); - test('create a bot via API, verify it in UI, trigger it, and verify response', async ({ + test('create a bot via UI, trigger it, and verify response', async ({ page, }) => { - // --- Step 1: Create and enable bot via fanout API --- - const bot = await createFanoutConfig({ - type: 'bot', - name: 'E2E Test Bot', - config: { code: BOT_CODE }, - enabled: true, - }); - createdBotId = bot.id; - - // --- Step 2: Verify bot appears in settings UI --- - await page.goto('/'); + await openFanoutSettings(page); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); - await page.getByText('Settings').click(); - await page.getByRole('button', { name: /MQTT.*Automation/ }).click(); + await startIntegrationDraft(page, 'Python Bot'); + await expect(page.locator('#fanout-edit-name')).toHaveValue(/Python Bot #\d+/); + + await page.locator('#fanout-edit-name').fill('E2E Test Bot'); + + const codeEditor = page.getByLabel('Bot code editor'); + await codeEditor.click(); + await codeEditor.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); + await codeEditor.fill(BOT_CODE); + + await page.getByRole('button', { name: /Save as Enabled/i }).click(); + await expect(page.getByText('Integration saved and enabled')).toBeVisible(); - // The bot name should be visible in the integration list await expect(page.getByText('E2E Test Bot')).toBeVisible(); - // Exit settings page mode + const configs = await getFanoutConfigs(); + const createdBot = configs.find((config) => config.name === 'E2E Test Bot'); + if (createdBot) { + createdBotId = createdBot.id; + } + await page.getByRole('button', { name: /Back to Chat/i }).click(); - // --- Step 3: Trigger the bot --- await page.getByText('#flightless', { exact: true }).first().click(); const triggerMessage = `!e2etest ${Date.now()}`; @@ -61,8 +65,6 @@ test.describe('Bot functionality', () => { await input.fill(triggerMessage); await page.getByRole('button', { name: 'Send', exact: true }).click(); - // --- Step 4: Verify bot response appears --- - // Bot has ~2s delay before responding, plus radio send time await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 }); }); }); diff --git a/tests/e2e/specs/webhook.spec.ts b/tests/e2e/specs/webhook.spec.ts index 3a778a3..c9c397e 100644 --- a/tests/e2e/specs/webhook.spec.ts +++ b/tests/e2e/specs/webhook.spec.ts @@ -4,7 +4,12 @@ import { deleteFanoutConfig, getFanoutConfigs, } from '../helpers/api'; -import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout'; +import { + createCaptureServer, + fanoutHeader, + openFanoutSettings, + startIntegrationDraft, +} from '../helpers/fanout'; test.describe('Webhook integration settings', () => { let createdWebhookId: string | null = null; @@ -35,9 +40,7 @@ test.describe('Webhook integration settings', () => { await openFanoutSettings(page); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); - // Open add menu and pick Webhook - await page.getByRole('button', { name: 'Add Integration' }).click(); - await page.getByRole('menuitem', { name: 'Webhook' }).click(); + await startIntegrationDraft(page, 'Webhook'); // Should navigate to the detail/edit view with a numbered default name await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/); @@ -77,8 +80,7 @@ test.describe('Webhook integration settings', () => { await openFanoutSettings(page); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); - await page.getByRole('button', { name: 'Add Integration' }).click(); - await page.getByRole('menuitem', { name: 'Webhook' }).click(); + await startIntegrationDraft(page, 'Webhook'); await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/); await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');