diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index 6c92674..b7bd6aa 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -514,9 +514,10 @@ let lastChannelsStatusFailureAt: number | undefined; export async function buildChannelAccountsView( ctx: HostApiContext, - options?: { probe?: boolean }, + options?: { probe?: boolean; skipRuntime?: boolean }, ): Promise<{ channels: ChannelAccountsView[]; gatewayHealth: GatewayHealthSummary }> { const startedAt = Date.now(); + const skipRuntime = options?.skipRuntime === true; // Read config once and share across all sub-calls (was 5 readFile calls before). const openClawConfig = await readOpenClawConfig(); @@ -526,29 +527,31 @@ export async function buildChannelAccountsView( listAgentsSnapshotFromConfig(openClawConfig), ]); - let gatewayStatus: GatewayChannelStatusPayload | null; - try { - // probe=false uses cached runtime state (lighter); probe=true forces - // adapter-level connectivity checks for faster post-restart convergence. - const probe = options?.probe === true; - // 8s timeout — fail fast when Gateway is busy with AI tasks. - const rpcStartedAt = Date.now(); - gatewayStatus = await ctx.gatewayManager.rpc( - 'channels.status', - { probe }, - probe ? 5000 : 8000, - ); - lastChannelsStatusOkAt = Date.now(); - logger.info( - `[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}` - ); - } catch { - const probe = options?.probe === true; - lastChannelsStatusFailureAt = Date.now(); - logger.warn( - `[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms` - ); - gatewayStatus = null; + let gatewayStatus: GatewayChannelStatusPayload | null = null; + if (!skipRuntime) { + try { + // probe=false uses cached runtime state (lighter); probe=true forces + // adapter-level connectivity checks for faster post-restart convergence. + const probe = options?.probe === true; + // 8s timeout — fail fast when Gateway is busy with AI tasks. + const rpcStartedAt = Date.now(); + gatewayStatus = await ctx.gatewayManager.rpc( + 'channels.status', + { probe }, + probe ? 5000 : 8000, + ); + lastChannelsStatusOkAt = Date.now(); + logger.info( + `[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}` + ); + } catch { + const probe = options?.probe === true; + lastChannelsStatusFailureAt = Date.now(); + logger.warn( + `[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms` + ); + gatewayStatus = null; + } } const gatewayDiagnostics = ctx.gatewayManager.getDiagnostics?.() ?? { @@ -563,6 +566,7 @@ export async function buildChannelAccountsView( platform: process.platform, }); const gatewayHealthState = gatewayHealthStateForChannels(gatewayHealth.state); + const effectiveGatewayHealthState = skipRuntime ? undefined : gatewayHealthState; const channelTypes = new Set([ ...configuredChannels, @@ -613,7 +617,7 @@ export async function buildChannelAccountsView( const runtime = runtimeAccounts.find((item) => item.accountId === accountId); const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {}; const status = computeChannelRuntimeStatus(runtimeSnapshot, { - gatewayHealthState, + gatewayHealthState: effectiveGatewayHealthState, }); return { accountId, @@ -646,22 +650,24 @@ export async function buildChannelAccountsView( })); const hasRuntimeError = visibleAccountSnapshots.some((account) => typeof account.lastError === 'string' && account.lastError.trim()) || Boolean(channelSummary?.error?.trim() || channelSummary?.lastError?.trim()); - const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary); - const groupStatus = !gatewayStatus && ctx.gatewayManager.getStatus().state === 'running' + const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, { + gatewayHealthState: effectiveGatewayHealthState, + }); + const groupStatus = !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running' ? 'degraded' - : gatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected' + : effectiveGatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected' ? 'degraded' : pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, { - gatewayHealthState, + gatewayHealthState: effectiveGatewayHealthState, }); channels.push({ channelType: uiChannelType, defaultAccountId, status: groupStatus, - statusReason: !gatewayStatus && ctx.gatewayManager.getStatus().state === 'running' + statusReason: !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running' ? 'channels_status_timeout' - : groupStatus === 'degraded' + : groupStatus === 'degraded' && effectiveGatewayHealthState ? overlayStatusReason(gatewayHealth, 'gateway_degraded') : undefined, accounts, @@ -670,7 +676,7 @@ export async function buildChannelAccountsView( const sorted = channels.sort((left, right) => left.channelType.localeCompare(right.channelType)); logger.info( - `[channels.accounts] response probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}` + `[channels.accounts] response mode=${skipRuntime ? 'config' : 'runtime'} probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}` ); return { channels: sorted, gatewayHealth }; } @@ -1266,9 +1272,13 @@ export async function handleChannelRoutes( if (url.pathname === '/api/channels/accounts' && req.method === 'GET') { try { - const probe = url.searchParams.get('probe') === '1'; - logger.info(`[channels.accounts] request probe=${probe ? '1' : '0'}`); - const { channels, gatewayHealth } = await buildChannelAccountsView(ctx, { probe }); + const mode = url.searchParams.get('mode') === 'config' ? 'config' : 'runtime'; + const probe = mode !== 'config' && url.searchParams.get('probe') === '1'; + logger.info(`[channels.accounts] request mode=${mode} probe=${probe ? '1' : '0'}`); + const { channels, gatewayHealth } = await buildChannelAccountsView(ctx, { + probe, + skipRuntime: mode === 'config', + }); sendJson(res, 200, { success: true, channels, gatewayHealth }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4c9fc09..b8e9577 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -137,9 +137,11 @@ export function Sidebar() { let cancelled = false; const hasExistingMessages = useChatStore.getState().messages.length > 0; (async () => { - await loadSessions(); + await Promise.allSettled([ + loadSessions(), + loadHistory(hasExistingMessages), + ]); if (cancelled) return; - await loadHistory(hasExistingMessages); })(); return () => { cancelled = true; diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 1500889..6a8f3b9 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -98,6 +98,12 @@ interface DeleteTarget { accountId?: string; } +type FetchPageDataOptions = { + probe?: boolean; + configOnly?: boolean; + forceAgentsRefresh?: boolean; +}; + function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): ChannelGroupItem[] { if (target.accountId) { return groups @@ -143,7 +149,9 @@ export function Channels() { const [deleteTarget, setDeleteTarget] = useState(null); const convergenceRefreshTimersRef = useRef([]); const fetchInFlightRef = useRef(false); - const queuedFetchOptionsRef = useRef<{ probe?: boolean } | null>(null); + const queuedFetchOptionsRef = useRef(null); + const agentsFetchInFlightRef = useRef | null>(null); + const hasLoadedAgentsRef = useRef(false); const displayedChannelTypes = getPrimaryChannels(); const visibleChannelGroups = channelGroups; @@ -159,16 +167,46 @@ export function Channels() { const agentsRef = useRef(agents); agentsRef.current = agents; + const ensureAgentsLoaded = useCallback(async () => { + if (hasLoadedAgentsRef.current) return; + if (agentsFetchInFlightRef.current) { + await agentsFetchInFlightRef.current; + return; + } + + agentsFetchInFlightRef.current = (async () => { + try { + const agentsRes = await hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'); + if (!agentsRes.success) { + throw new Error(agentsRes.error || 'Failed to load agents'); + } + setAgents(agentsRes.agents || []); + hasLoadedAgentsRef.current = true; + } catch (agentsError) { + console.warn(`[channels-ui] load agents failed error=${String(agentsError)}`); + } finally { + agentsFetchInFlightRef.current = null; + } + })(); + + await agentsFetchInFlightRef.current; + }, []); + const mergeFetchOptions = ( - base: { probe?: boolean } | null, - incoming: { probe?: boolean } | undefined, - ): { probe?: boolean } => { + base: FetchPageDataOptions | null, + incoming: FetchPageDataOptions | undefined, + ): FetchPageDataOptions => { + if (!base) return incoming ?? {}; + if (!incoming) return base; return { probe: Boolean(base?.probe) || Boolean(incoming?.probe), + // If either request needs runtime data, do not keep config-only mode. + configOnly: Boolean(base?.configOnly) && Boolean(incoming?.configOnly), + forceAgentsRefresh: Boolean(base?.forceAgentsRefresh) || Boolean(incoming?.forceAgentsRefresh), }; }; - const fetchPageData = useCallback(async (options?: { probe?: boolean }) => { + const fetchPageData = useCallback(async (options?: FetchPageDataOptions) => { if (fetchInFlightRef.current) { queuedFetchOptionsRef.current = mergeFetchOptions(queuedFetchOptionsRef.current, options); return; @@ -176,20 +214,27 @@ export function Channels() { fetchInFlightRef.current = true; const startedAt = Date.now(); const probe = options?.probe === true; - console.info(`[channels-ui] fetch start probe=${probe ? '1' : '0'}`); + const configOnly = options?.configOnly === true; + console.info(`[channels-ui] fetch start mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'}`); // Only show loading spinner on first load (stale-while-revalidate). const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0; if (!hasData) { setLoading(true); } setError(null); + if (options?.forceAgentsRefresh) { + hasLoadedAgentsRef.current = false; + } + void ensureAgentsLoaded(); try { - const [channelsRes, agentsRes] = await Promise.all([ - hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>( - options?.probe ? '/api/channels/accounts?probe=1' : '/api/channels/accounts' - ), - hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'), - ]); + const channelsPath = configOnly + ? '/api/channels/accounts?mode=config' + : options?.probe + ? '/api/channels/accounts?probe=1' + : '/api/channels/accounts'; + const channelsRes = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>( + channelsPath + ); type ChannelsResponse = { success: boolean; @@ -203,23 +248,18 @@ export function Channels() { throw new Error(channelsPayload.error || 'Failed to load channels'); } - if (!agentsRes.success) { - throw new Error(agentsRes.error || 'Failed to load agents'); - } - setChannelGroups(channelsPayload.channels || []); - setAgents(agentsRes.agents || []); setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH); setDiagnosticsSnapshot(null); setShowDiagnostics(false); console.info( - `[channels-ui] fetch ok probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsPayload.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}` + `[channels-ui] fetch ok mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsPayload.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}` ); } catch (fetchError) { // Preserve previous data on error — don't clear channelGroups/agents. setError(String(fetchError)); console.warn( - `[channels-ui] fetch fail probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}` + `[channels-ui] fetch fail mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}` ); } finally { fetchInFlightRef.current = false; @@ -232,7 +272,7 @@ export function Channels() { } // Stable reference — reads state via refs, no deps needed. - }, []); + }, [ensureAgentsLoaded]); const clearConvergenceRefreshTimers = useCallback(() => { convergenceRefreshTimersRef.current.forEach((timerId) => { @@ -261,6 +301,7 @@ export function Channels() { }, [clearConvergenceRefreshTimers, fetchPageData]); useEffect(() => { + void fetchPageData({ configOnly: true }); void fetchPageData(); }, [fetchPageData]); @@ -329,7 +370,7 @@ export function Channels() { const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type)); const handleRefresh = () => { - void fetchPageData({ probe: true }); + void fetchPageData({ probe: true, forceAgentsRefresh: true }); }; const fetchDiagnosticsSnapshot = useCallback(async (): Promise => { diff --git a/src/stores/cron.ts b/src/stores/cron.ts index 0e61a4f..349adde 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -7,6 +7,8 @@ import { hostApiFetch } from '@/lib/host-api'; import { useChatStore } from './chat'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; +let _fetchJobsInFlight: Promise | null = null; + interface CronState { jobs: CronJob[]; loading: boolean; @@ -28,28 +30,41 @@ export const useCronStore = create((set) => ({ error: null, fetchJobs: async () => { - const currentJobs = useCronStore.getState().jobs; - // Only show loading spinner when there's no data yet (stale-while-revalidate). - if (currentJobs.length === 0) { - set({ loading: true, error: null }); - } else { - set({ error: null }); + if (_fetchJobsInFlight) { + await _fetchJobsInFlight; + return; } + _fetchJobsInFlight = (async () => { + const currentJobs = useCronStore.getState().jobs; + // Only show loading spinner when there's no data yet (stale-while-revalidate). + if (currentJobs.length === 0) { + set({ loading: true, error: null }); + } else { + set({ error: null }); + } + + try { + const result = await hostApiFetch('/api/cron/jobs'); + + // Gateway now correctly returns agentId for all jobs. + // If Gateway returned fewer jobs than we have (e.g. race condition), preserve + // the extra ones from current state to avoid losing data. + const resultIds = new Set(result.map((j) => j.id)); + const extraJobs = currentJobs.filter((j) => !resultIds.has(j.id)); + const allJobs = [...result, ...extraJobs]; + + set({ jobs: allJobs, loading: false }); + } catch (error) { + // Preserve previous jobs on error so the user sees stale data instead of nothing. + set({ error: String(error), loading: false }); + } + })(); + try { - const result = await hostApiFetch('/api/cron/jobs'); - - // Gateway now correctly returns agentId for all jobs. - // If Gateway returned fewer jobs than we have (e.g. race condition), preserve - // the extra ones from current state to avoid losing data. - const resultIds = new Set(result.map((j) => j.id)); - const extraJobs = currentJobs.filter((j) => !resultIds.has(j.id)); - const allJobs = [...result, ...extraJobs]; - - set({ jobs: allJobs, loading: false }); - } catch (error) { - // Preserve previous jobs on error so the user sees stale data instead of nothing. - set({ error: String(error), loading: false }); + await _fetchJobsInFlight; + } finally { + _fetchJobsInFlight = null; } }, diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 15be3ab..aeac44f 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -92,14 +92,15 @@ export const useSkillsStore = create((set, get) => ({ set({ loading: true, error: null }); } try { - // 1. Fetch from Gateway (running skills) - const gatewayData = await useGatewayStore.getState().rpc('skills.status'); - - // 2. Fetch from ClawHub (installed on disk) - const clawhubResult = await hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list'); - - // 3. Fetch configurations directly from Electron (since Gateway doesn't return them) - const configResult = await hostApiFetch }>>('/api/skills/configs'); + // Fetch all skill sources in parallel to reduce first-load latency. + const gatewayDataPromise = useGatewayStore.getState().rpc('skills.status'); + const clawhubResultPromise = hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list'); + const configResultPromise = hostApiFetch }>>('/api/skills/configs'); + const [gatewayData, clawhubResult, configResult] = await Promise.all([ + gatewayDataPromise, + clawhubResultPromise, + configResultPromise, + ]); let combinedSkills: Skill[] = []; const currentSkills = get().skills; diff --git a/tests/unit/channels-page.test.tsx b/tests/unit/channels-page.test.tsx index 638bfba..14fca3f 100644 --- a/tests/unit/channels-page.test.tsx +++ b/tests/unit/channels-page.test.tsx @@ -59,7 +59,7 @@ describe('Channels page status refresh', () => { }); gatewayState.status = { state: 'running', port: 18789 }; hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, gatewayHealth: { @@ -100,7 +100,7 @@ describe('Channels page status refresh', () => { it('blocks saving when custom account ID is non-canonical', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, channels: [ @@ -210,7 +210,7 @@ describe('Channels page status refresh', () => { 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); + expect(agentFetchCalls).toHaveLength(1); }); }); @@ -233,14 +233,59 @@ describe('Channels page status refresh', () => { 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); + expect(agentFetchCalls).toHaveLength(1); + }); + }); + + it('renders channel data without waiting for slow agents request', async () => { + subscribeHostEventMock.mockImplementation(() => vi.fn()); + + const agentsDeferred = createDeferred<{ + success: boolean; + agents: Array>; + }>(); + + hostApiFetchMock.mockImplementation((path: string) => { + if (path.startsWith('/api/channels/accounts')) { + return Promise.resolve({ + 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 agentsDeferred.promise; + } + throw new Error(`Unexpected host API path: ${path}`); + }); + + render(); + + expect(await screen.findByText('Feishu / Lark')).toBeInTheDocument(); + + await act(async () => { + agentsDeferred.resolve({ success: true, agents: [] }); }); }); it('treats WeChat accounts as plugin-managed QR accounts', async () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, channels: [ @@ -305,7 +350,7 @@ describe('Channels page status refresh', () => { let refreshCallCount = 0; hostApiFetchMock.mockImplementation((path: string) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { if (refreshCallCount === 0) { refreshCallCount += 1; return Promise.resolve({ @@ -401,7 +446,7 @@ describe('Channels page status refresh', () => { const writeTextMock = vi.mocked(navigator.clipboard.writeText); hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, gatewayHealth: { @@ -477,7 +522,7 @@ describe('Channels page status refresh', () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, gatewayHealth: { @@ -530,7 +575,7 @@ describe('Channels page status refresh', () => { subscribeHostEventMock.mockImplementation(() => vi.fn()); hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, gatewayHealth: { @@ -584,7 +629,7 @@ describe('Channels page status refresh', () => { let diagnosticsFetchCount = 0; hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/channels/accounts') { + if (path.startsWith('/api/channels/accounts')) { return { success: true, gatewayHealth: { diff --git a/tests/unit/cron-store-fetch-dedupe.test.ts b/tests/unit/cron-store-fetch-dedupe.test.ts new file mode 100644 index 0000000..4f732cd --- /dev/null +++ b/tests/unit/cron-store-fetch-dedupe.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApiFetchMock = vi.fn(); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +vi.mock('@/stores/chat', () => ({ + useChatStore: { + getState: () => ({ + currentAgentId: 'main', + }), + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('cron store fetchJobs dedupe', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('reuses in-flight fetchJobs request when called concurrently', async () => { + const listDeferred = deferred>(); + hostApiFetchMock.mockReturnValueOnce(listDeferred.promise); + + const { useCronStore } = await import('@/stores/cron'); + useCronStore.setState({ jobs: [], loading: false, error: null }); + + const first = useCronStore.getState().fetchJobs(); + const second = useCronStore.getState().fetchJobs(); + await Promise.resolve(); + + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/cron/jobs'); + + listDeferred.resolve([{ id: 'job-1' }]); + await Promise.all([first, second]); + + expect(useCronStore.getState().jobs.map((job) => job.id)).toEqual(['job-1']); + }); +}); diff --git a/tests/unit/skills-store-fetch-parallel.test.ts b/tests/unit/skills-store-fetch-parallel.test.ts new file mode 100644 index 0000000..91be726 --- /dev/null +++ b/tests/unit/skills-store-fetch-parallel.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApiFetchMock = vi.fn(); +const rpcMock = vi.fn(); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: { + getState: () => ({ + rpc: (...args: unknown[]) => rpcMock(...args), + }), + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('skills store fetch parallelization', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('starts clawhub and config requests before gateway rpc resolves', async () => { + const gatewayDeferred = deferred<{ skills: Array> }>(); + rpcMock.mockReturnValueOnce(gatewayDeferred.promise); + hostApiFetchMock.mockImplementation((path: unknown) => { + if (path === '/api/clawhub/list') return Promise.resolve({ success: true, results: [] }); + if (path === '/api/skills/configs') return Promise.resolve({}); + return Promise.reject(new Error(`Unexpected path: ${String(path)}`)); + }); + + const { useSkillsStore } = await import('@/stores/skills'); + useSkillsStore.setState({ skills: [], loading: false, error: null }); + + const fetchPromise = useSkillsStore.getState().fetchSkills(); + await Promise.resolve(); + + expect(rpcMock).toHaveBeenCalledWith('skills.status'); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/clawhub/list'); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/configs'); + + gatewayDeferred.resolve({ skills: [] }); + await fetchPromise; + }); +});