import React, { act, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useGraphMemberLogPreviews } from '@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews'; import type { MemberLogPreviewResponse } from '@features/member-log-stream/contracts'; const apiMock = vi.hoisted(() => ({ memberLogStream: { getMemberLogPreviews: vi.fn(), }, teams: { onTeamChange: vi.fn(), }, })); vi.mock('@renderer/api', () => ({ api: apiMock, })); function createDeferred(): { promise: Promise; resolve: (value: T) => void; } { let resolve!: (value: T) => void; const promise = new Promise((innerResolve) => { resolve = innerResolve; }); return { promise, resolve }; } function response(memberName: string, generatedAt: string): MemberLogPreviewResponse { return { generatedAt, members: [ { memberName, items: [ { id: `${memberName}:${generatedAt}`, kind: 'text', provider: 'claude_transcript', timestamp: generatedAt, title: 'Assistant', preview: memberName, tone: 'neutral', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt, }, ], }; } function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse { return { generatedAt, members: memberNames.map((memberName) => ({ memberName, items: [ { id: `${memberName}:${generatedAt}`, kind: 'text', provider: 'claude_transcript', timestamp: generatedAt, title: 'Assistant', preview: memberName, tone: 'neutral', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt, })), }; } const HookProbe = ({ teamName, memberNames, laneIdsByMember, enabled = true, onState, }: { teamName: string; memberNames: string[]; laneIdsByMember?: Record; enabled?: boolean; onState: (state: ReturnType) => void; }): React.JSX.Element | null => { const state = useGraphMemberLogPreviews({ teamName, memberNames, laneIdsByMember, enabled, }); useEffect(() => { onState(state); }, [onState, state]); return null; }; describe('useGraphMemberLogPreviews', () => { beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); apiMock.memberLogStream.getMemberLogPreviews.mockReset(); apiMock.teams.onTeamChange.mockReset(); apiMock.teams.onTeamChange.mockReturnValue(() => undefined); Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible', }); }); afterEach(() => { document.body.innerHTML = ''; vi.useRealTimers(); vi.unstubAllGlobals(); }); it('debounces visible member batch requests and passes safe lane ids', async () => { apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( response('alice', '2026-04-03T00:00:00.000Z') ); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( undefined} /> ); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled(); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledWith( 'alpha-team', ['alice'], expect.objectContaining({ maxItemsPerMember: 3, textLimit: 200, laneIdsByMember: { alice: 'secondary:opencode:alice' }, }) ); act(() => { root.unmount(); }); }); it('keeps completed previews cached after the visible member set changes', async () => { const aliceLoad = createDeferred(); const bobLoad = createDeferred(); apiMock.memberLogStream.getMemberLogPreviews .mockReturnValueOnce(aliceLoad.promise) .mockReturnValueOnce(bobLoad.promise); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const states: ReturnType[] = []; const onState = vi.fn((state: ReturnType) => { states.push(state); }); const latestState = (): ReturnType | undefined => states.at(-1); await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); await act(async () => { aliceLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); await act(async () => { bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z')); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob'); act(() => { root.unmount(); }); }); it('keeps cached previews while pan or zoom changes the visible member batch', async () => { const bobLoad = createDeferred(); apiMock.memberLogStream.getMemberLogPreviews .mockResolvedValueOnce(response('alice', '2026-04-03T00:00:00.000Z')) .mockReturnValueOnce(bobLoad.promise); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const states: ReturnType[] = []; const onState = vi.fn((state: ReturnType) => { states.push(state); }); const latestState = (): ReturnType | undefined => states.at(-1); await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); await act(async () => { root.render(); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); await act(async () => { bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z')); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob'); act(() => { root.unmount(); }); }); it('does not duplicate preview requests when the same visible members are reordered', async () => { const firstLoad = createDeferred(); apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( undefined} /> ); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( 'alpha-team', ['alice', 'bob'], expect.any(Object) ); await act(async () => { root.render( undefined} /> ); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); await act(async () => { firstLoad.resolve(batchResponse(['alice', 'bob'], '2026-04-03T00:00:00.000Z')); await Promise.resolve(); }); await act(async () => { root.render( undefined} /> ); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); act(() => { root.unmount(); }); }); it('ignores stale responses when the same member receives a newer lane request', async () => { const oldLaneLoad = createDeferred(); const newLaneLoad = createDeferred(); apiMock.memberLogStream.getMemberLogPreviews .mockReturnValueOnce(oldLaneLoad.promise) .mockReturnValueOnce(newLaneLoad.promise); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const states: ReturnType[] = []; const onState = vi.fn((state: ReturnType) => { states.push(state); }); const latestState = (): ReturnType | undefined => states.at(-1); await act(async () => { root.render( ); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); await act(async () => { root.render( ); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); await act(async () => { newLaneLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( 'alice:2026-04-03T00:01:00.000Z' ); await act(async () => { oldLaneLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); await Promise.resolve(); }); expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( 'alice:2026-04-03T00:01:00.000Z' ); act(() => { root.unmount(); }); }); it('reloads visible members on log change events with force refresh', async () => { let teamChangeListener: | ((event: unknown, data: { teamName: string; type: string }) => void) | null = null; apiMock.teams.onTeamChange.mockImplementation((callback) => { teamChangeListener = callback as typeof teamChangeListener; return () => undefined; }); apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( response('alice', '2026-04-03T00:00:00.000Z') ); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( undefined} /> ); await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); await act(async () => { teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' }); vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( 'alpha-team', ['alice'], expect.objectContaining({ forceRefresh: true }) ); await act(async () => { teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' }); vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(3); expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( 'alpha-team', ['alice'], expect.objectContaining({ forceRefresh: true }) ); act(() => { root.unmount(); }); }); });