import React, { act, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useMemberLogStream } from '../useMemberLogStream'; import type { MemberLogStreamResponse } from '../../../contracts'; import type { ResolvedTeamMember } from '@shared/types'; const apiMock = vi.hoisted(() => ({ memberLogStream: { getMemberLogStream: vi.fn(), setMemberLogStreamTracking: 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 member(name: string): ResolvedTeamMember { return { name, status: 'idle', currentTaskId: null, taskCount: 0, lastActiveAt: null, messageCount: 0, }; } function response(generatedAt: string): MemberLogStreamResponse { return { participants: [], defaultFilter: 'all', segments: [], source: 'member_empty', coverage: [], warnings: [], truncated: false, generatedAt, metadata: { scannedTranscriptFileCount: 0, includedTranscriptFileCount: 0, droppedSegmentCount: 0, droppedChunkCount: 0, droppedMessageCount: 0, }, }; } const HookProbe = ({ teamName, selectedMember, enabled = true, onState, }: { teamName: string; selectedMember: ResolvedTeamMember; enabled?: boolean; onState: (state: ReturnType) => void; }): React.JSX.Element | null => { const state = useMemberLogStream({ teamName, member: selectedMember, enabled }); useEffect(() => { onState(state); }, [onState, state]); return null; }; describe('useMemberLogStream', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); apiMock.memberLogStream.getMemberLogStream.mockReset(); apiMock.memberLogStream.setMemberLogStreamTracking.mockReset(); apiMock.memberLogStream.setMemberLogStreamTracking.mockResolvedValue(undefined); apiMock.teams.onTeamChange.mockReset(); apiMock.teams.onTeamChange.mockReturnValue(() => undefined); }); afterEach(() => { document.body.innerHTML = ''; vi.useRealTimers(); vi.unstubAllGlobals(); }); it('does not let an older in-flight member request drive a pending reload after member key changes', async () => { const aliceLoad = createDeferred(); const bobLoad = createDeferred(); apiMock.memberLogStream.getMemberLogStream .mockReturnValueOnce(aliceLoad.promise) .mockReturnValueOnce(bobLoad.promise); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onState = vi.fn((_: ReturnType) => undefined); const latestState = (): ReturnType | undefined => onState.mock.calls.at(-1)?.[0]; await act(async () => { root.render( ); await Promise.resolve(); }); await act(async () => { root.render( ); await Promise.resolve(); }); const requestedMembers = apiMock.memberLogStream.getMemberLogStream.mock.calls.map( (call: unknown[]) => String(call[1]) ); expect(requestedMembers).toEqual(['alice', 'bob']); await act(async () => { aliceLoad.resolve(response('2026-04-03T00:00:00.000Z')); await Promise.resolve(); }); expect(latestState()?.stream).toBeNull(); await act(async () => { bobLoad.resolve(response('2026-04-03T00:01:00.000Z')); await Promise.resolve(); }); expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:01:00.000Z'); act(() => { root.unmount(); }); }); it('reloads on same-team log events with forceRefresh only for source changes', async () => { vi.useFakeTimers(); 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.getMemberLogStream .mockResolvedValueOnce(response('2026-04-03T00:00:00.000Z')) .mockResolvedValueOnce(response('2026-04-03T00:01:00.000Z')) .mockResolvedValueOnce(response('2026-04-03T00:02: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.getMemberLogStream).toHaveBeenCalledTimes(1); await act(async () => { teamChangeListener?.(null, { teamName: 'other-team', type: 'log-source-change' }); vi.advanceTimersByTime(700); await Promise.resolve(); }); await act(async () => { teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); await act(async () => { teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' }); vi.advanceTimersByTime(700); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2); expect(apiMock.memberLogStream.getMemberLogStream).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.getMemberLogStream).toHaveBeenCalledTimes(3); expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith( 'alpha-team', 'alice', expect.not.objectContaining({ forceRefresh: true }) ); act(() => { root.unmount(); }); }); it('releases stale in-flight state when the section is disabled before a request finishes', async () => { const firstLoad = createDeferred(); apiMock.memberLogStream.getMemberLogStream .mockReturnValueOnce(firstLoad.promise) .mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z')); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onState = vi.fn((_: ReturnType) => undefined); const latestState = (): ReturnType | undefined => onState.mock.calls.at(-1)?.[0]; const selectedMember = member('alice'); await act(async () => { root.render( ); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); await act(async () => { root.render( ); await Promise.resolve(); }); await act(async () => { firstLoad.resolve(response('2026-04-03T00:01:00.000Z')); await Promise.resolve(); }); expect(latestState()?.stream).toBeNull(); await act(async () => { root.render( ); await Promise.resolve(); }); expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2); expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:02:00.000Z'); act(() => { root.unmount(); }); }); it('passes an OpenCode lane only for OpenCode-owned members', async () => { apiMock.memberLogStream.getMemberLogStream.mockResolvedValue( response('2026-04-03T00:00:00.000Z') ); const staleLaneMember: ResolvedTeamMember = { ...member('alice'), providerId: 'anthropic', laneId: 'secondary:opencode:alice', laneOwnerProviderId: 'opencode', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( undefined} /> ); await Promise.resolve(); }); const request = apiMock.memberLogStream.getMemberLogStream.mock.calls[0] as | [string, string, { laneId?: unknown }] | undefined; expect(request?.[0]).toBe('alpha-team'); expect(request?.[1]).toBe('alice'); expect(request?.[2].laneId).toBeUndefined(); act(() => { root.unmount(); }); }); });