From d4367d326516db6b2b05711a1167cec1785e0763 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Tue, 17 Mar 2026 11:29:17 +0800 Subject: [PATCH] refactor(channels): integrate channel runtime status management and enhance account status handling (#547) --- electron/api/routes/channels.ts | 45 ++---- electron/api/routes/providers.ts | 2 +- src/lib/channel-status.ts | 101 +++++++++++++ src/pages/Agents/index.tsx | 26 +++- src/pages/Channels/index.tsx | 12 +- src/stores/channels.ts | 43 +++--- tests/unit/agents-page.test.tsx | 121 ++++++++++++++++ tests/unit/channel-routes.test.ts | 229 ++++++++++++++++++++++++++++++ tests/unit/channel-status.test.ts | 66 +++++++++ tests/unit/channels-page.test.tsx | 129 +++++++++++++++++ 10 files changed, 713 insertions(+), 61 deletions(-) create mode 100644 src/lib/channel-status.ts create mode 100644 tests/unit/agents-page.test.tsx create mode 100644 tests/unit/channel-routes.test.ts create mode 100644 tests/unit/channel-status.test.ts create mode 100644 tests/unit/channels-page.test.tsx diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index ff8deedaa..ff856db1a 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -24,6 +24,11 @@ import { ensureQQBotPluginInstalled, ensureWeComPluginInstalled, } from '../../utils/plugin-install'; +import { + computeChannelRuntimeStatus, + pickChannelRuntimeStatus, + type ChannelRuntimeAccountSnapshot, +} from '../../../src/lib/channel-status'; import { whatsAppLoginManager } from '../../utils/whatsapp-login'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; @@ -123,6 +128,10 @@ interface GatewayChannelStatusPayload { lastConnectedAt?: number | null; lastInboundAt?: number | null; lastOutboundAt?: number | null; + lastProbeAt?: number | null; + probe?: { + ok?: boolean; + } | null; }>>; channelDefaultAccountId?: Record; } @@ -147,35 +156,6 @@ interface ChannelAccountsView { 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 { const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([ listConfiguredChannels(), @@ -202,6 +182,8 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise { const runtime = runtimeAccounts.find((item) => item.accountId === accountId); - const status = computeAccountStatus(runtime ?? {}); + const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {}; + const status = computeChannelRuntimeStatus(runtimeSnapshot); return { accountId, name: runtime?.name || accountId, @@ -243,7 +226,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise 0; +} + +export function hasRecentChannelActivity( + account: Pick, + 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, +): boolean { + return account.probe?.ok === true; +} + +export function hasChannelRuntimeError( + account: Pick, +): 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'; +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 58eacfbeb..ed1652c67 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -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 { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { useAgentsStore } from '@/stores/agents'; import { useGatewayStore } from '@/stores/gateway'; import { hostApiFetch } from '@/lib/host-api'; +import { subscribeHostEvent } from '@/lib/host-events'; import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel'; import type { AgentSummary } from '@/types/agent'; import { useTranslation } from 'react-i18next'; @@ -43,6 +44,7 @@ interface ChannelGroupItem { export function Agents() { const { t } = useTranslation('agents'); const gatewayStatus = useGatewayStore((state) => state.status); + const lastGatewayStateRef = useRef(gatewayStatus.state); const { agents, loading, @@ -70,6 +72,28 @@ export function Agents() { // eslint-disable-next-line react-hooks/set-state-in-effect void Promise.all([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( () => agents.find((agent) => agent.id === activeAgentId) ?? null, [activeAgentId, agents], diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index dcabe9720..67121b82a 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -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 { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -73,6 +73,7 @@ function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): export function Channels() { const { t } = useTranslation('channels'); const gatewayStatus = useGatewayStore((state) => state.status); + const lastGatewayStateRef = useRef(gatewayStatus.state); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -130,6 +131,15 @@ export function Channels() { }; }, [fetchPageData]); + useEffect(() => { + const previousGatewayState = lastGatewayStateRef.current; + lastGatewayStateRef.current = gatewayStatus.state; + + if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') { + void fetchPageData(); + } + }, [fetchPageData, gatewayStatus.state]); + const configuredTypes = useMemo( () => channelGroups.map((group) => group.channelType), [channelGroups], diff --git a/src/stores/channels.ts b/src/stores/channels.ts index f5d87aaaf..f1aa68cdf 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -4,6 +4,11 @@ */ import { create } from 'zustand'; import { hostApiFetch } from '@/lib/host-api'; +import { + isChannelRuntimeConnected, + pickChannelRuntimeStatus, + type ChannelRuntimeAccountSnapshot, +} from '@/lib/channel-status'; import { useGatewayStore } from './gateway'; import { CHANNEL_NAMES, type Channel, type ChannelType } from '../types/channel'; @@ -52,6 +57,10 @@ export const useChannelsStore = create((set, get) => ({ lastConnectedAt?: number | null; lastInboundAt?: number | null; lastOutboundAt?: number | null; + lastProbeAt?: number | null; + probe?: { + ok?: boolean; + } | null; }>>; channelDefaultAccountId?: Record; }>('channels.status', { probe: true }); @@ -72,39 +81,19 @@ export const useChannelsStore = create((set, get) => ({ const accounts = data.channelAccounts?.[channelId] || []; const defaultAccountId = data.channelDefaultAccountId?.[channelId]; + const summarySignal = summary as { error?: string; lastError?: string } | undefined; const primaryAccount = (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]; - // Map gateway status to our status format - 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 status: Channel['status'] = pickChannelRuntimeStatus(accounts, summarySignal); const summaryError = - typeof (summary as { error?: string })?.error === 'string' - ? (summary as { error?: string }).error - : typeof (summary as { lastError?: string })?.lastError === 'string' - ? (summary as { lastError?: string }).lastError + typeof summarySignal?.error === 'string' + ? summarySignal.error + : typeof summarySignal?.lastError === 'string' + ? summarySignal.lastError : 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({ id: `${channelId}-${primaryAccount?.accountId || 'default'}`, diff --git a/tests/unit/agents-page.test.tsx b/tests/unit/agents-page.test.tsx new file mode 100644 index 000000000..46adec1e0 --- /dev/null +++ b/tests/unit/agents-page.test.tsx @@ -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>, + 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; + deleteAgent: ReturnType; + }) => 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(); + + 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(); + + await waitFor(() => { + expect(fetchAgentsMock).toHaveBeenCalledTimes(1); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts'); + }); + + gatewayState.status = { state: 'running', port: 18789 }; + await act(async () => { + rerender(); + }); + + await waitFor(() => { + const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); + expect(channelFetchCalls).toHaveLength(2); + }); + }); +}); diff --git a/tests/unit/channel-routes.test.ts b/tests/unit/channel-routes.test.ts new file mode 100644 index 000000000..b8b2fd69c --- /dev/null +++ b/tests/unit/channel-routes.test.ts @@ -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' }), + ]), + }), + ], + }), + ); + }); +}); diff --git a/tests/unit/channel-status.test.ts b/tests/unit/channel-status.test.ts new file mode 100644 index 000000000..3d8db0118 --- /dev/null +++ b/tests/unit/channel-status.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/channels-page.test.tsx b/tests/unit/channels-page.test.tsx new file mode 100644 index 000000000..5d1d17481 --- /dev/null +++ b/tests/unit/channels-page.test.tsx @@ -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(); + + 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(); + + await waitFor(() => { + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/channels/accounts'); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/agents'); + }); + + gatewayState.status = { state: 'running', port: 18789 }; + await act(async () => { + rerender(); + }); + + 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); + }); + }); +});