refactor(channels): integrate channel runtime status management and enhance account status handling (#547)
This commit is contained in:
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