agent-ecosystem/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx
2026-05-07 18:07:00 +03:00

420 lines
12 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { MemberLogPreviewMember } from '@features/member-log-stream/contracts/dto';
const basePreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'team-lead',
{
memberName: 'team-lead',
items: [
{
id: 'lead-preview-1',
kind: 'text' as const,
provider: 'claude_transcript' as const,
timestamp: '2026-04-03T00:00:00.000Z',
title: 'Assistant',
preview: 'lead log preview',
tone: 'neutral' as const,
},
],
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
warnings: [],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
[
'alice',
{
memberName: 'alice',
items: [
{
id: 'preview-1',
kind: 'tool_use' as const,
provider: 'claude_transcript' as const,
timestamp: '2026-04-03T00:00:00.000Z',
title: 'Bash',
preview: 'pnpm test',
tone: 'warning' as const,
},
{
id: 'preview-2',
kind: 'tool_result' as const,
provider: 'opencode_runtime' as const,
timestamp: '2026-04-03T00:00:30.000Z',
title: 'Send message error',
preview: 'OpenCode tool failed without output',
tone: 'error' as const,
},
{
id: 'preview-3',
kind: 'tool_result' as const,
provider: 'opencode_runtime' as const,
timestamp: '2026-04-03T00:00:40.000Z',
title: 'Bash result',
preview: 'Tests passed',
tone: 'success' as const,
},
],
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
warnings: [],
truncated: true,
overflowCount: 2,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
]);
let mockedPreviewsByMember = basePreviewsByMember;
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
useGraphMemberLogPreviews: () => ({
previewsByMember: mockedPreviewsByMember,
loading: false,
error: null,
reload: vi.fn(),
}),
}));
vi.mock('@features/agent-graph/renderer/hooks/useGraphActivityContext', () => ({
useGraphActivityContext: () => ({
teamData: {
members: [
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
providerId: 'opencode',
laneOwnerProviderId: 'opencode',
laneId: 'secondary:opencode:alice',
},
],
},
}),
}));
describe('GraphMemberLogPreviewHud', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1)
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
mockedPreviewsByMember = basePreviewsByMember;
});
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('opens the member profile on the logs tab when a preview row or overflow is clicked', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'active',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
const onOpenMemberProfile = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
onOpenMemberProfile={onOpenMemberProfile}
/>
);
await Promise.resolve();
});
const row = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('pnpm test')
);
expect(row).not.toBeUndefined();
expect(row?.querySelector('.float-left')).not.toBeNull();
expect(row?.querySelector('.line-clamp-3')).toBeNull();
expect(row?.className).toContain('h-16');
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-4');
expect(row?.textContent).toContain('pnpm test');
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('OpenCode tool failed')
);
expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull();
const resultRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Tests passed')
);
expect(resultRow?.textContent).toContain('Bash');
expect(resultRow?.textContent).not.toContain('Bash result');
await act(async () => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onOpenMemberProfile).toHaveBeenCalledWith('alice', { initialTab: 'logs' });
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('+2 more')
);
expect(moreButton).not.toBeUndefined();
await act(async () => {
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onOpenMemberProfile).toHaveBeenCalledTimes(2);
act(() => {
root.unmount();
});
});
it('briefly highlights a newly appeared preview row', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'active',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const renderHud = (): void => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
};
await act(async () => {
renderHud();
await Promise.resolve();
});
const alicePreview = basePreviewsByMember.get('alice')!;
mockedPreviewsByMember = new Map(basePreviewsByMember);
mockedPreviewsByMember.set('alice', {
...alicePreview,
items: [
{
id: 'preview-new',
kind: 'text' as const,
provider: 'claude_transcript' as const,
timestamp: '2026-04-03T00:01:00.000Z',
title: 'Assistant',
preview: 'new compact log',
tone: 'neutral' as const,
},
...alicePreview.items,
],
});
await act(async () => {
renderHud();
await Promise.resolve();
});
const newRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('new compact log')
);
expect(newRow?.className).toContain('border-sky-300/70');
await act(async () => {
vi.advanceTimersByTime(1_000);
await Promise.resolve();
});
expect(newRow?.className).not.toContain('border-sky-300/70');
act(() => {
root.unmount();
});
});
it('renders lead log previews and opens the lead profile logs tab', async () => {
const leadNode: GraphNode = {
id: 'lead:alpha-team',
kind: 'lead',
label: 'alpha-team',
state: 'active',
domainRef: { kind: 'lead', teamName: 'alpha-team', memberName: 'team-lead' },
};
const onOpenMemberProfile = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[leadNode]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
onOpenMemberProfile={onOpenMemberProfile}
/>
);
await Promise.resolve();
});
const row = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('lead log preview')
);
expect(row).not.toBeUndefined();
await act(async () => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onOpenMemberProfile).toHaveBeenCalledWith('team-lead', { initialTab: 'logs' });
act(() => {
root.unmount();
});
});
it('keeps compact event text readable without repeating the title prefix', async () => {
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [
{
id: 'message-sent-preview',
kind: 'tool_result',
provider: 'claude_transcript',
timestamp: '2026-04-03T00:01:00.000Z',
title: 'Message sent',
preview: 'Message sent to team-lead - #abc done',
tone: 'success',
},
{
id: 'generic-tool-result-preview',
kind: 'tool_result',
provider: 'claude_transcript',
timestamp: '2026-04-03T00:00:50.000Z',
title: 'Tool result',
preview: 'stored',
tone: 'success',
},
],
coverage: [{ provider: 'claude_transcript', status: 'included' }],
warnings: [],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:01:00.000Z',
},
],
]);
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'active',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
const messageRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('#abc done')
);
expect(messageRow?.textContent).toContain('Message sent');
expect(messageRow?.textContent).toContain('to team-lead - #abc done');
expect(messageRow?.textContent).not.toContain('Message sentMessage sent');
expect(messageRow?.textContent).not.toContain('Message sent now Message sent');
const genericResultRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('stored')
);
expect(genericResultRow?.textContent).toContain('Tool result');
act(() => {
root.unmount();
});
});
});