agent-ecosystem/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx
2026-05-07 20:14:59 +03:00

384 lines
12 KiB
TypeScript

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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<string, string>;
enabled?: boolean;
onState: (state: ReturnType<typeof useGraphMemberLogPreviews>) => 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(
<HookProbe
teamName="alpha-team"
memberNames={['alice', 'alice']}
laneIdsByMember={{
alice: 'secondary:opencode:alice',
bob: 'secondary:opencode:bob',
}}
onState={() => 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<MemberLogPreviewResponse>();
const bobLoad = createDeferred<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
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<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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(<HookProbe teamName="alpha-team" memberNames={[]} onState={onState} />);
await Promise.resolve();
});
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
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<MemberLogPreviewResponse>();
apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" memberNames={['alice', 'bob']} onState={() => 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(
<HookProbe teamName="alpha-team" memberNames={['bob', 'alice']} onState={() => 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(
<HookProbe teamName="alpha-team" memberNames={['alice', 'bob']} onState={() => undefined} />
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
it('reloads visible members on log-source 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(
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => 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 })
);
act(() => {
root.unmount();
});
});
});