384 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|