Make a better integration/fanout selector

This commit is contained in:
jkingsman
2026-03-24 13:48:50 -07:00
parent b022aea71f
commit e8a4f5c349
6 changed files with 501 additions and 269 deletions

View File

@@ -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<string, string> = {
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<string, unknown>;
scope: Record<string, unknown>;
};
};
function createCommunityConfigDefaults(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
@@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
return "[BOT] Plong!"
return None`;
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
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<string, unknown>;
scope: Record<string, unknown>;
};
};
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<DraftType, DraftRecipe> = {
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<DraftType, DraftRecipe> = {
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<DraftType, DraftRecipe> = {
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<DraftType, DraftRecipe> = {
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<DraftType, DraftRecipe> = {
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<DraftType, DraftRecipe> = {
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<DraftType, DraftRecipe> = {
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<DraftType, CreateIntegrationDefinition>;
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<string, unknown>) {
@@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
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<HTMLDivElement | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
aria-describedby={undefined}
hideCloseButton
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
>
<DialogHeader className="border-b border-border px-5 py-4">
<DialogTitle>Create Integration</DialogTitle>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
<div
ref={listRef}
onScroll={updateScrollHint}
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
>
<div className="space-y-4">
{sectionedOptions.map((group) => (
<div key={group.section} className="space-y-1.5">
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{group.section}
</div>
{group.options.map((option) => {
const selected = option.value === selectedOption?.value;
return (
<button
key={option.value}
type="button"
className={cn(
'w-full rounded-md border px-3 py-2 text-left transition-colors',
selected
? 'border-primary bg-accent text-foreground'
: 'border-transparent bg-transparent hover:bg-accent/70'
)}
aria-pressed={selected}
onClick={() => onSelect(option.value)}
>
<div className="text-sm font-medium">{option.label}</div>
</button>
);
})}
</div>
))}
</div>
</div>
{showScrollHint && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</div>
</div>
)}
</div>
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
{selectedOption ? (
<>
<div className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{selectedOption.section}
</div>
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{selectedOption.description}
</p>
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No integration types are currently available.
</div>
)}
</div>
</div>
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={onCreate} disabled={!selectedOption}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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<string | null>(null);
const [inlineEditName, setInlineEditName] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
const [busy, setBusy] = useState(false);
const addMenuRef = useRef<HTMLDivElement | null>(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({
</div>
)}
<div className="relative inline-block" ref={addMenuRef}>
<Button
type="button"
size="sm"
aria-haspopup="menu"
aria-expanded={addMenuOpen}
onClick={() => setAddMenuOpen((open) => !open)}
>
Add Integration
</Button>
{addMenuOpen && (
<div
role="menu"
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
>
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
(opt) => (
<button
key={opt.value}
type="button"
role="menuitem"
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</button>
)
)}
</div>
)}
</div>
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
Add Integration
</Button>
<CreateIntegrationDialog
open={createDialogOpen}
options={availableCreateOptions}
selectedType={selectedCreateType}
onOpenChange={setCreateDialogOpen}
onSelect={setSelectedCreateType}
onCreate={() => {
if (selectedCreateType) {
handleAddCreate(selectedCreateType);
}
}}
/>
{configGroups.length > 0 && (
<div className="columns-1 gap-4 md:columns-2">

View File

@@ -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:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
).toBeInTheDocument();
expect(
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, 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 () => {

View File

@@ -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<void> {
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
}
export async function startIntegrationDraft(page: Page, integrationName: string): Promise<void> {
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

View File

@@ -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+/);

View File

@@ -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 });
});
});

View File

@@ -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');