refactor(channels): integrate channel runtime status management and enhance account status handling (#547)
This commit is contained in:
@@ -24,6 +24,11 @@ import {
|
|||||||
ensureQQBotPluginInstalled,
|
ensureQQBotPluginInstalled,
|
||||||
ensureWeComPluginInstalled,
|
ensureWeComPluginInstalled,
|
||||||
} from '../../utils/plugin-install';
|
} from '../../utils/plugin-install';
|
||||||
|
import {
|
||||||
|
computeChannelRuntimeStatus,
|
||||||
|
pickChannelRuntimeStatus,
|
||||||
|
type ChannelRuntimeAccountSnapshot,
|
||||||
|
} from '../../../src/lib/channel-status';
|
||||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody, sendJson } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
@@ -123,6 +128,10 @@ interface GatewayChannelStatusPayload {
|
|||||||
lastConnectedAt?: number | null;
|
lastConnectedAt?: number | null;
|
||||||
lastInboundAt?: number | null;
|
lastInboundAt?: number | null;
|
||||||
lastOutboundAt?: number | null;
|
lastOutboundAt?: number | null;
|
||||||
|
lastProbeAt?: number | null;
|
||||||
|
probe?: {
|
||||||
|
ok?: boolean;
|
||||||
|
} | null;
|
||||||
}>>;
|
}>>;
|
||||||
channelDefaultAccountId?: Record<string, string>;
|
channelDefaultAccountId?: Record<string, string>;
|
||||||
}
|
}
|
||||||
@@ -147,35 +156,6 @@ interface ChannelAccountsView {
|
|||||||
accounts: ChannelAccountView[];
|
accounts: ChannelAccountView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeAccountStatus(account: {
|
|
||||||
connected?: boolean;
|
|
||||||
linked?: boolean;
|
|
||||||
running?: boolean;
|
|
||||||
lastError?: string;
|
|
||||||
lastConnectedAt?: number | null;
|
|
||||||
lastInboundAt?: number | null;
|
|
||||||
lastOutboundAt?: number | null;
|
|
||||||
}): 'connected' | 'connecting' | 'disconnected' | 'error' {
|
|
||||||
const now = Date.now();
|
|
||||||
const recentMs = 10 * 60 * 1000;
|
|
||||||
const hasRecentActivity =
|
|
||||||
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs)
|
|
||||||
|| (typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs)
|
|
||||||
|| (typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs);
|
|
||||||
|
|
||||||
if (account.connected === true || account.linked === true || hasRecentActivity) return 'connected';
|
|
||||||
if (account.running === true && !account.lastError) return 'connecting';
|
|
||||||
if (account.lastError) return 'error';
|
|
||||||
return 'disconnected';
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickChannelStatus(accounts: ChannelAccountView[]): 'connected' | 'connecting' | 'disconnected' | 'error' {
|
|
||||||
if (accounts.some((account) => account.status === 'connected')) return 'connected';
|
|
||||||
if (accounts.some((account) => account.status === 'error')) return 'error';
|
|
||||||
if (accounts.some((account) => account.status === 'connecting')) return 'connecting';
|
|
||||||
return 'disconnected';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
|
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
|
||||||
const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([
|
const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([
|
||||||
listConfiguredChannels(),
|
listConfiguredChannels(),
|
||||||
@@ -202,6 +182,8 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
const channelAccountsFromConfig = configuredAccounts[channelType]?.accountIds ?? [];
|
const channelAccountsFromConfig = configuredAccounts[channelType]?.accountIds ?? [];
|
||||||
const hasLocalConfig = configuredChannels.includes(channelType) || Boolean(configuredAccounts[channelType]);
|
const hasLocalConfig = configuredChannels.includes(channelType) || Boolean(configuredAccounts[channelType]);
|
||||||
const channelSection = openClawConfig.channels?.[channelType];
|
const channelSection = openClawConfig.channels?.[channelType];
|
||||||
|
const channelSummary =
|
||||||
|
(gatewayStatus?.channels?.[channelType] as { error?: string; lastError?: string } | undefined) ?? undefined;
|
||||||
const fallbackDefault =
|
const fallbackDefault =
|
||||||
typeof channelSection?.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
typeof channelSection?.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||||
? channelSection.defaultAccount
|
? channelSection.defaultAccount
|
||||||
@@ -221,7 +203,8 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
|
|
||||||
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
|
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
|
||||||
const runtime = runtimeAccounts.find((item) => item.accountId === accountId);
|
const runtime = runtimeAccounts.find((item) => item.accountId === accountId);
|
||||||
const status = computeAccountStatus(runtime ?? {});
|
const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {};
|
||||||
|
const status = computeChannelRuntimeStatus(runtimeSnapshot);
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
name: runtime?.name || accountId,
|
name: runtime?.name || accountId,
|
||||||
@@ -243,7 +226,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
channels.push({
|
channels.push({
|
||||||
channelType,
|
channelType,
|
||||||
defaultAccountId,
|
defaultAccountId,
|
||||||
status: pickChannelStatus(accounts),
|
status: pickChannelRuntimeStatus(runtimeAccounts, channelSummary),
|
||||||
accounts,
|
accounts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export async function handleProviderRoutes(
|
|||||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||||
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||||
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
|
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
|
||||||
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol as any }));
|
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 500, { valid: false, error: String(error) });
|
sendJson(res, 500, { valid: false, error: String(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/lib/channel-status.ts
Normal file
101
src/lib/channel-status.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||||
|
|
||||||
|
export interface ChannelRuntimeAccountSnapshot {
|
||||||
|
connected?: boolean;
|
||||||
|
linked?: boolean;
|
||||||
|
running?: boolean;
|
||||||
|
lastError?: string | null;
|
||||||
|
lastConnectedAt?: number | null;
|
||||||
|
lastInboundAt?: number | null;
|
||||||
|
lastOutboundAt?: number | null;
|
||||||
|
lastProbeAt?: number | null;
|
||||||
|
probe?: {
|
||||||
|
ok?: boolean | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelRuntimeSummarySnapshot {
|
||||||
|
error?: string | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_ACTIVITY_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function hasNonEmptyError(value: string | null | undefined): boolean {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRecentChannelActivity(
|
||||||
|
account: Pick<ChannelRuntimeAccountSnapshot, 'lastConnectedAt' | 'lastInboundAt' | 'lastOutboundAt'>,
|
||||||
|
now = Date.now(),
|
||||||
|
recentMs = RECENT_ACTIVITY_MS,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs) ||
|
||||||
|
(typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs) ||
|
||||||
|
(typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSuccessfulChannelProbe(
|
||||||
|
account: Pick<ChannelRuntimeAccountSnapshot, 'probe'>,
|
||||||
|
): boolean {
|
||||||
|
return account.probe?.ok === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasChannelRuntimeError(
|
||||||
|
account: Pick<ChannelRuntimeAccountSnapshot, 'lastError'>,
|
||||||
|
): boolean {
|
||||||
|
return hasNonEmptyError(account.lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSummaryRuntimeError(
|
||||||
|
summary: ChannelRuntimeSummarySnapshot | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!summary) return false;
|
||||||
|
return hasNonEmptyError(summary.error) || hasNonEmptyError(summary.lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChannelRuntimeConnected(
|
||||||
|
account: ChannelRuntimeAccountSnapshot,
|
||||||
|
): boolean {
|
||||||
|
if (account.connected === true || account.linked === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRecentChannelActivity(account) || hasSuccessfulChannelProbe(account)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenClaw integrations such as Feishu/WeCom may stay "running" without ever
|
||||||
|
// setting a durable connected=true flag. Treat healthy running as connected.
|
||||||
|
return account.running === true && !hasChannelRuntimeError(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeChannelRuntimeStatus(
|
||||||
|
account: ChannelRuntimeAccountSnapshot,
|
||||||
|
): ChannelConnectionStatus {
|
||||||
|
if (isChannelRuntimeConnected(account)) return 'connected';
|
||||||
|
if (hasChannelRuntimeError(account)) return 'error';
|
||||||
|
if (account.running === true) return 'connecting';
|
||||||
|
return 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickChannelRuntimeStatus(
|
||||||
|
accounts: ChannelRuntimeAccountSnapshot[],
|
||||||
|
summary?: ChannelRuntimeSummarySnapshot,
|
||||||
|
): ChannelConnectionStatus {
|
||||||
|
if (accounts.some((account) => isChannelRuntimeConnected(account))) {
|
||||||
|
return 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.some((account) => hasChannelRuntimeError(account)) || hasSummaryRuntimeError(summary)) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.some((account) => account.running === true)) {
|
||||||
|
return 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'disconnected';
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react';
|
import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
|||||||
import { useAgentsStore } from '@/stores/agents';
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
|
import { subscribeHostEvent } from '@/lib/host-events';
|
||||||
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
||||||
import type { AgentSummary } from '@/types/agent';
|
import type { AgentSummary } from '@/types/agent';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -43,6 +44,7 @@ interface ChannelGroupItem {
|
|||||||
export function Agents() {
|
export function Agents() {
|
||||||
const { t } = useTranslation('agents');
|
const { t } = useTranslation('agents');
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||||
const {
|
const {
|
||||||
agents,
|
agents,
|
||||||
loading,
|
loading,
|
||||||
@@ -70,6 +72,28 @@ export function Agents() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
||||||
}, [fetchAgents, fetchChannelAccounts]);
|
}, [fetchAgents, fetchChannelAccounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
|
||||||
|
void fetchChannelAccounts();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (typeof unsubscribe === 'function') {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchChannelAccounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousGatewayState = lastGatewayStateRef.current;
|
||||||
|
lastGatewayStateRef.current = gatewayStatus.state;
|
||||||
|
|
||||||
|
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void fetchChannelAccounts();
|
||||||
|
}
|
||||||
|
}, [fetchChannelAccounts, gatewayStatus.state]);
|
||||||
|
|
||||||
const activeAgent = useMemo(
|
const activeAgent = useMemo(
|
||||||
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
|
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
|
||||||
[activeAgentId, agents],
|
[activeAgentId, agents],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { RefreshCw, Trash2, AlertCircle, Plus } from 'lucide-react';
|
import { RefreshCw, Trash2, AlertCircle, Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -73,6 +73,7 @@ function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget):
|
|||||||
export function Channels() {
|
export function Channels() {
|
||||||
const { t } = useTranslation('channels');
|
const { t } = useTranslation('channels');
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -130,6 +131,15 @@ export function Channels() {
|
|||||||
};
|
};
|
||||||
}, [fetchPageData]);
|
}, [fetchPageData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousGatewayState = lastGatewayStateRef.current;
|
||||||
|
lastGatewayStateRef.current = gatewayStatus.state;
|
||||||
|
|
||||||
|
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
||||||
|
void fetchPageData();
|
||||||
|
}
|
||||||
|
}, [fetchPageData, gatewayStatus.state]);
|
||||||
|
|
||||||
const configuredTypes = useMemo(
|
const configuredTypes = useMemo(
|
||||||
() => channelGroups.map((group) => group.channelType),
|
() => channelGroups.map((group) => group.channelType),
|
||||||
[channelGroups],
|
[channelGroups],
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
|
import {
|
||||||
|
isChannelRuntimeConnected,
|
||||||
|
pickChannelRuntimeStatus,
|
||||||
|
type ChannelRuntimeAccountSnapshot,
|
||||||
|
} from '@/lib/channel-status';
|
||||||
import { useGatewayStore } from './gateway';
|
import { useGatewayStore } from './gateway';
|
||||||
import { CHANNEL_NAMES, type Channel, type ChannelType } from '../types/channel';
|
import { CHANNEL_NAMES, type Channel, type ChannelType } from '../types/channel';
|
||||||
|
|
||||||
@@ -52,6 +57,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
|||||||
lastConnectedAt?: number | null;
|
lastConnectedAt?: number | null;
|
||||||
lastInboundAt?: number | null;
|
lastInboundAt?: number | null;
|
||||||
lastOutboundAt?: number | null;
|
lastOutboundAt?: number | null;
|
||||||
|
lastProbeAt?: number | null;
|
||||||
|
probe?: {
|
||||||
|
ok?: boolean;
|
||||||
|
} | null;
|
||||||
}>>;
|
}>>;
|
||||||
channelDefaultAccountId?: Record<string, string>;
|
channelDefaultAccountId?: Record<string, string>;
|
||||||
}>('channels.status', { probe: true });
|
}>('channels.status', { probe: true });
|
||||||
@@ -72,39 +81,19 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
|||||||
|
|
||||||
const accounts = data.channelAccounts?.[channelId] || [];
|
const accounts = data.channelAccounts?.[channelId] || [];
|
||||||
const defaultAccountId = data.channelDefaultAccountId?.[channelId];
|
const defaultAccountId = data.channelDefaultAccountId?.[channelId];
|
||||||
|
const summarySignal = summary as { error?: string; lastError?: string } | undefined;
|
||||||
const primaryAccount =
|
const primaryAccount =
|
||||||
(defaultAccountId ? accounts.find((a) => a.accountId === defaultAccountId) : undefined) ||
|
(defaultAccountId ? accounts.find((a) => a.accountId === defaultAccountId) : undefined) ||
|
||||||
accounts.find((a) => a.connected === true || a.linked === true) ||
|
accounts.find((a) => isChannelRuntimeConnected(a as ChannelRuntimeAccountSnapshot)) ||
|
||||||
accounts[0];
|
accounts[0];
|
||||||
|
|
||||||
// Map gateway status to our status format
|
const status: Channel['status'] = pickChannelRuntimeStatus(accounts, summarySignal);
|
||||||
let status: Channel['status'] = 'disconnected';
|
|
||||||
const now = Date.now();
|
|
||||||
const RECENT_MS = 10 * 60 * 1000;
|
|
||||||
const hasRecentActivity = (a: { lastInboundAt?: number | null; lastOutboundAt?: number | null; lastConnectedAt?: number | null }) =>
|
|
||||||
(typeof a.lastInboundAt === 'number' && now - a.lastInboundAt < RECENT_MS) ||
|
|
||||||
(typeof a.lastOutboundAt === 'number' && now - a.lastOutboundAt < RECENT_MS) ||
|
|
||||||
(typeof a.lastConnectedAt === 'number' && now - a.lastConnectedAt < RECENT_MS);
|
|
||||||
const anyConnected = accounts.some((a) => a.connected === true || a.linked === true || hasRecentActivity(a));
|
|
||||||
const anyRunning = accounts.some((a) => a.running === true);
|
|
||||||
const summaryError =
|
const summaryError =
|
||||||
typeof (summary as { error?: string })?.error === 'string'
|
typeof summarySignal?.error === 'string'
|
||||||
? (summary as { error?: string }).error
|
? summarySignal.error
|
||||||
: typeof (summary as { lastError?: string })?.lastError === 'string'
|
: typeof summarySignal?.lastError === 'string'
|
||||||
? (summary as { lastError?: string }).lastError
|
? summarySignal.lastError
|
||||||
: undefined;
|
: undefined;
|
||||||
const anyError =
|
|
||||||
accounts.some((a) => typeof a.lastError === 'string' && a.lastError) || Boolean(summaryError);
|
|
||||||
|
|
||||||
if (anyConnected) {
|
|
||||||
status = 'connected';
|
|
||||||
} else if (anyRunning && !anyError) {
|
|
||||||
status = 'connected';
|
|
||||||
} else if (anyError) {
|
|
||||||
status = 'error';
|
|
||||||
} else if (anyRunning) {
|
|
||||||
status = 'connecting';
|
|
||||||
}
|
|
||||||
|
|
||||||
channels.push({
|
channels.push({
|
||||||
id: `${channelId}-${primaryAccount?.accountId || 'default'}`,
|
id: `${channelId}-${primaryAccount?.accountId || 'default'}`,
|
||||||
|
|||||||
121
tests/unit/agents-page.test.tsx
Normal file
121
tests/unit/agents-page.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, waitFor } from '@testing-library/react';
|
||||||
|
import { Agents } from '../../src/pages/Agents/index';
|
||||||
|
|
||||||
|
const hostApiFetchMock = vi.fn();
|
||||||
|
const subscribeHostEventMock = vi.fn();
|
||||||
|
const fetchAgentsMock = vi.fn();
|
||||||
|
|
||||||
|
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
||||||
|
gatewayState: {
|
||||||
|
status: { state: 'running', port: 18789 },
|
||||||
|
},
|
||||||
|
agentsState: {
|
||||||
|
agents: [] as Array<Record<string, unknown>>,
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/gateway', () => ({
|
||||||
|
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/agents', () => ({
|
||||||
|
useAgentsStore: (selector?: (state: typeof agentsState & {
|
||||||
|
fetchAgents: typeof fetchAgentsMock;
|
||||||
|
createAgent: ReturnType<typeof vi.fn>;
|
||||||
|
deleteAgent: ReturnType<typeof vi.fn>;
|
||||||
|
}) => unknown) => {
|
||||||
|
const state = {
|
||||||
|
...agentsState,
|
||||||
|
fetchAgents: fetchAgentsMock,
|
||||||
|
createAgent: vi.fn(),
|
||||||
|
deleteAgent: vi.fn(),
|
||||||
|
};
|
||||||
|
return typeof selector === 'function' ? selector(state) : state;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-events', () => ({
|
||||||
|
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Agents page status refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
gatewayState.status = { state: 'running', port: 18789 };
|
||||||
|
fetchAgentsMock.mockResolvedValue(undefined);
|
||||||
|
hostApiFetchMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
channels: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
||||||
|
let channelStatusHandler: (() => void) | undefined;
|
||||||
|
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
||||||
|
if (eventName === 'gateway:channel-status') {
|
||||||
|
channelStatusHandler = handler;
|
||||||
|
}
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Agents />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchAgentsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts');
|
||||||
|
});
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
channelStatusHandler?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts');
|
||||||
|
expect(channelFetchCalls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches channel accounts when the gateway transitions to running after mount', async () => {
|
||||||
|
gatewayState.status = { state: 'starting', port: 18789 };
|
||||||
|
|
||||||
|
const { rerender } = render(<Agents />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchAgentsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts');
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayState.status = { state: 'running', port: 18789 };
|
||||||
|
await act(async () => {
|
||||||
|
rerender(<Agents />);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts');
|
||||||
|
expect(channelFetchCalls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
229
tests/unit/channel-routes.test.ts
Normal file
229
tests/unit/channel-routes.test.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
|
||||||
|
const listConfiguredChannelsMock = vi.fn();
|
||||||
|
const listConfiguredChannelAccountsMock = vi.fn();
|
||||||
|
const readOpenClawConfigMock = vi.fn();
|
||||||
|
const listAgentsSnapshotMock = vi.fn();
|
||||||
|
const sendJsonMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/channel-config', () => ({
|
||||||
|
deleteChannelAccountConfig: vi.fn(),
|
||||||
|
deleteChannelConfig: vi.fn(),
|
||||||
|
getChannelFormValues: vi.fn(),
|
||||||
|
listConfiguredChannelAccounts: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args),
|
||||||
|
listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
||||||
|
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
|
||||||
|
saveChannelConfig: vi.fn(),
|
||||||
|
setChannelDefaultAccount: vi.fn(),
|
||||||
|
setChannelEnabled: vi.fn(),
|
||||||
|
validateChannelConfig: vi.fn(),
|
||||||
|
validateChannelCredentials: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/agent-config', () => ({
|
||||||
|
assignChannelAccountToAgent: vi.fn(),
|
||||||
|
clearAllBindingsForChannel: vi.fn(),
|
||||||
|
clearChannelBinding: vi.fn(),
|
||||||
|
listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/plugin-install', () => ({
|
||||||
|
ensureDingTalkPluginInstalled: vi.fn(),
|
||||||
|
ensureFeishuPluginInstalled: vi.fn(),
|
||||||
|
ensureQQBotPluginInstalled: vi.fn(),
|
||||||
|
ensureWeComPluginInstalled: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/whatsapp-login', () => ({
|
||||||
|
whatsAppLoginManager: {
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/api/route-utils', () => ({
|
||||||
|
parseJsonBody: vi.fn().mockResolvedValue({}),
|
||||||
|
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('handleChannelRoutes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
entries: [],
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
channels: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports healthy running multi-account channels as connected', async () => {
|
||||||
|
listConfiguredChannelsMock.mockResolvedValue(['feishu']);
|
||||||
|
listConfiguredChannelAccountsMock.mockResolvedValue({
|
||||||
|
feishu: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'feishu-2412524e'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
defaultAccount: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
entries: [],
|
||||||
|
channelAccountOwners: {
|
||||||
|
'feishu:default': 'main',
|
||||||
|
'feishu:feishu-2412524e': 'code',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rpc = vi.fn().mockResolvedValue({
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channelAccounts: {
|
||||||
|
feishu: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
configured: true,
|
||||||
|
connected: false,
|
||||||
|
running: true,
|
||||||
|
linked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: 'feishu-2412524e',
|
||||||
|
configured: true,
|
||||||
|
connected: false,
|
||||||
|
running: true,
|
||||||
|
linked: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channelDefaultAccountId: {
|
||||||
|
feishu: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
const handled = await handleChannelRoutes(
|
||||||
|
{ method: 'GET' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:3210/api/channels/accounts'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc,
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(rpc).toHaveBeenCalledWith('channels.status', { probe: true });
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
200,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
expect.objectContaining({
|
||||||
|
channelType: 'feishu',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ accountId: 'default', status: 'connected' }),
|
||||||
|
expect.objectContaining({ accountId: 'feishu-2412524e', status: 'connected' }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps channel connected when one account is healthy and another errors', async () => {
|
||||||
|
listConfiguredChannelsMock.mockResolvedValue(['telegram']);
|
||||||
|
listConfiguredChannelAccountsMock.mockResolvedValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-b'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
defaultAccount: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rpc = vi.fn().mockResolvedValue({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channelAccounts: {
|
||||||
|
telegram: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
configured: true,
|
||||||
|
connected: true,
|
||||||
|
running: true,
|
||||||
|
linked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: 'telegram-b',
|
||||||
|
configured: true,
|
||||||
|
connected: false,
|
||||||
|
running: false,
|
||||||
|
linked: false,
|
||||||
|
lastError: 'secondary bot failed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channelDefaultAccountId: {
|
||||||
|
telegram: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'GET' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:3210/api/channels/accounts'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc,
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
200,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
expect.objectContaining({
|
||||||
|
channelType: 'telegram',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ accountId: 'default', status: 'connected' }),
|
||||||
|
expect.objectContaining({ accountId: 'telegram-b', status: 'error' }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/unit/channel-status.test.ts
Normal file
66
tests/unit/channel-status.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
computeChannelRuntimeStatus,
|
||||||
|
pickChannelRuntimeStatus,
|
||||||
|
} from '@/lib/channel-status';
|
||||||
|
|
||||||
|
describe('channel runtime status helpers', () => {
|
||||||
|
it('treats healthy running channels as connected', () => {
|
||||||
|
expect(
|
||||||
|
computeChannelRuntimeStatus({
|
||||||
|
running: true,
|
||||||
|
connected: false,
|
||||||
|
linked: false,
|
||||||
|
}),
|
||||||
|
).toBe('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats successful probes as connected for forward compatibility', () => {
|
||||||
|
expect(
|
||||||
|
computeChannelRuntimeStatus({
|
||||||
|
probe: { ok: true },
|
||||||
|
running: false,
|
||||||
|
}),
|
||||||
|
).toBe('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when runtime reports a lastError', () => {
|
||||||
|
expect(
|
||||||
|
computeChannelRuntimeStatus({
|
||||||
|
running: true,
|
||||||
|
lastError: 'bot token invalid',
|
||||||
|
}),
|
||||||
|
).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns disconnected for empty runtime state', () => {
|
||||||
|
expect(computeChannelRuntimeStatus({})).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps connected status when another account has an error', () => {
|
||||||
|
expect(
|
||||||
|
pickChannelRuntimeStatus([
|
||||||
|
{ connected: true },
|
||||||
|
{ lastError: 'boom' },
|
||||||
|
]),
|
||||||
|
).toBe('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats multi-account healthy running channels as connected', () => {
|
||||||
|
expect(
|
||||||
|
pickChannelRuntimeStatus([
|
||||||
|
{ running: true, connected: false },
|
||||||
|
{ running: true, connected: false },
|
||||||
|
]),
|
||||||
|
).toBe('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses summary-level errors when no account is connected', () => {
|
||||||
|
expect(
|
||||||
|
pickChannelRuntimeStatus(
|
||||||
|
[{ accountId: 'default', connected: false, running: false }],
|
||||||
|
{ error: 'channel bootstrap failed' },
|
||||||
|
),
|
||||||
|
).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
129
tests/unit/channels-page.test.tsx
Normal file
129
tests/unit/channels-page.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, waitFor } from '@testing-library/react';
|
||||||
|
import { Channels } from '@/pages/Channels/index';
|
||||||
|
|
||||||
|
const hostApiFetchMock = vi.fn();
|
||||||
|
const subscribeHostEventMock = vi.fn();
|
||||||
|
|
||||||
|
const { gatewayState } = vi.hoisted(() => ({
|
||||||
|
gatewayState: {
|
||||||
|
status: { state: 'running', port: 18789 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/gateway', () => ({
|
||||||
|
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-events', () => ({
|
||||||
|
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Channels page status refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
gatewayState.status = { state: 'running', port: 18789 };
|
||||||
|
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||||
|
if (path === '/api/channels/accounts') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: 'feishu',
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
name: 'Primary Account',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/agents') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
agents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected host API path: ${path}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
||||||
|
let channelStatusHandler: (() => void) | undefined;
|
||||||
|
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
||||||
|
if (eventName === 'gateway:channel-status') {
|
||||||
|
channelStatusHandler = handler;
|
||||||
|
}
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Channels />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts');
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/agents');
|
||||||
|
});
|
||||||
|
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
channelStatusHandler?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts');
|
||||||
|
const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents');
|
||||||
|
expect(channelFetchCalls).toHaveLength(2);
|
||||||
|
expect(agentFetchCalls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches when the gateway transitions to running after mount', async () => {
|
||||||
|
gatewayState.status = { state: 'starting', port: 18789 };
|
||||||
|
|
||||||
|
const { rerender } = render(<Channels />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts');
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/agents');
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayState.status = { state: 'running', port: 18789 };
|
||||||
|
await act(async () => {
|
||||||
|
rerender(<Channels />);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts');
|
||||||
|
const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents');
|
||||||
|
expect(channelFetchCalls).toHaveLength(2);
|
||||||
|
expect(agentFetchCalls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user