import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, 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 updateAgentMock = vi.fn(); const updateAgentModelMock = vi.fn(); const refreshProviderSnapshotMock = vi.fn(); const { gatewayState, agentsState, providersState } = vi.hoisted(() => ({ gatewayState: { status: { state: 'running', port: 18789 }, }, agentsState: { agents: [] as Array>, defaultModelRef: null as string | null, loading: false, error: null as string | null, }, providersState: { accounts: [] as Array>, statuses: [] as Array>, vendors: [] as Array>, defaultAccountId: '' as string, }, })); vi.mock('@/stores/gateway', () => ({ useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState), })); vi.mock('@/stores/agents', () => ({ useAgentsStore: (selector?: (state: typeof agentsState & { fetchAgents: typeof fetchAgentsMock; updateAgent: typeof updateAgentMock; updateAgentModel: typeof updateAgentModelMock; createAgent: ReturnType; deleteAgent: ReturnType; }) => unknown) => { const state = { ...agentsState, fetchAgents: fetchAgentsMock, updateAgent: updateAgentMock, updateAgentModel: updateAgentModelMock, createAgent: vi.fn(), deleteAgent: vi.fn(), }; return typeof selector === 'function' ? selector(state) : state; }, })); vi.mock('@/stores/providers', () => ({ useProviderStore: (selector: (state: typeof providersState & { refreshProviderSnapshot: typeof refreshProviderSnapshotMock; }) => unknown) => { const state = { ...providersState, refreshProviderSnapshot: refreshProviderSnapshotMock, }; return selector(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 }; agentsState.agents = []; agentsState.defaultModelRef = null; providersState.accounts = []; providersState.statuses = []; providersState.vendors = []; providersState.defaultAccountId = ''; fetchAgentsMock.mockResolvedValue(undefined); updateAgentMock.mockResolvedValue(undefined); updateAgentModelMock.mockResolvedValue(undefined); refreshProviderSnapshotMock.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); }); }); it('uses "Use default model" as form fill only and disables it when already default', async () => { agentsState.agents = [ { id: 'main', name: 'Main', isDefault: true, modelDisplay: 'claude-opus-4.6', modelRef: 'openrouter/anthropic/claude-opus-4.6', overrideModelRef: null, inheritedModel: true, workspace: '~/.openclaw/workspace', agentDir: '~/.openclaw/agents/main/agent', mainSessionKey: 'agent:main:desk', channelTypes: [], }, ]; agentsState.defaultModelRef = 'openrouter/anthropic/claude-opus-4.6'; providersState.accounts = [ { id: 'openrouter-default', label: 'OpenRouter', vendorId: 'openrouter', authMode: 'api_key', model: 'openrouter/anthropic/claude-opus-4.6', enabled: true, createdAt: '2026-03-24T00:00:00.000Z', updatedAt: '2026-03-24T00:00:00.000Z', }, ]; providersState.statuses = [{ id: 'openrouter-default', hasKey: true }]; providersState.vendors = [ { id: 'openrouter', name: 'OpenRouter', modelIdPlaceholder: 'anthropic/claude-opus-4.6' }, ]; providersState.defaultAccountId = 'openrouter-default'; render(); await waitFor(() => { expect(fetchAgentsMock).toHaveBeenCalledTimes(1); }); fireEvent.click(screen.getByTitle('settings')); fireEvent.click(screen.getByText('settingsDialog.modelLabel').closest('button') as HTMLButtonElement); const useDefaultButton = await screen.findByRole('button', { name: 'settingsDialog.useDefaultModel' }); const modelIdInput = screen.getByLabelText('settingsDialog.modelIdLabel'); const saveButton = screen.getByRole('button', { name: 'common:actions.save' }); expect(useDefaultButton).toBeDisabled(); fireEvent.change(modelIdInput, { target: { value: 'anthropic/claude-sonnet-4.5' } }); expect(useDefaultButton).toBeEnabled(); expect(saveButton).toBeEnabled(); fireEvent.click(useDefaultButton); expect(updateAgentModelMock).not.toHaveBeenCalled(); expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6'); expect(useDefaultButton).toBeDisabled(); }); it('keeps the last agent snapshot visible while a refresh is in flight', async () => { agentsState.agents = [ { id: 'main', name: 'Main', isDefault: true, modelDisplay: 'gpt-5', modelRef: 'openai/gpt-5', overrideModelRef: null, inheritedModel: true, workspace: '~/.openclaw/workspace', agentDir: '~/.openclaw/agents/main/agent', mainSessionKey: 'agent:main:main', channelTypes: [], }, ]; const { rerender } = render(); expect(await screen.findByText('Main')).toBeInTheDocument(); agentsState.loading = true; await act(async () => { rerender(); }); expect(screen.getByText('Main')).toBeInTheDocument(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); it('keeps the blocking spinner during the initial load before any stable snapshot exists', async () => { agentsState.loading = true; fetchAgentsMock.mockImplementation(() => new Promise(() => {})); refreshProviderSnapshotMock.mockImplementation(() => new Promise(() => {})); hostApiFetchMock.mockImplementation(() => new Promise(() => {})); const { container } = render(); expect(container.querySelector('svg.animate-spin')).toBeTruthy(); expect(screen.queryByText('title')).not.toBeInTheDocument(); }); });