agent-ecosystem/test/renderer/features/agent-graph/GraphActivityHud.test.ts

501 lines
14 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { InboxMessage } from '@shared/types/team';
const teamState = {
selectedTeamName: 'demo-team',
selectedTeamData: {
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', agentType: 'developer' },
],
tasks: [],
},
teamDataCacheByName: new Map<string, { members: Record<string, unknown>[]; tasks: unknown[] }>([
[
'demo-team',
{
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', agentType: 'developer' },
],
tasks: [],
},
],
]),
teams: [],
};
const buildInlineActivityEntries = vi.fn();
const originalOffsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth'
);
const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight'
);
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof teamState) => unknown) => selector(teamState),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
selectTeamDataForName: (_state: typeof teamState, teamName: string) =>
teamState.teamDataCacheByName.get(teamName) ??
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null),
selectResolvedMembersForTeamName: (_state: typeof teamState, teamName: string) =>
(
teamState.teamDataCacheByName.get(teamName) ??
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null)
)?.members ?? [],
selectTeamMessages: () => [],
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector,
}));
vi.mock('@renderer/hooks/useTeamMessagesRead', () => ({
useTeamMessagesRead: () => ({
readSet: new Set<string>(),
markRead: vi.fn(),
markAllRead: vi.fn(),
}),
}));
vi.mock('@renderer/hooks/useStableTeamMentionMeta', () => ({
useStableTeamMentionMeta: () => ({
teamNames: [],
teamColorByName: new Map(),
}),
}));
vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
ActivityItem: ({ message }: { message: InboxMessage }) =>
React.createElement('div', { 'data-testid': 'activity-item' }, message.summary ?? message.text),
}));
vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
MessageExpandDialog: () => null,
}));
vi.mock('@renderer/components/team/activity/activityMessageContext', () => ({
buildMessageContext: () => ({
colorMap: new Map(),
localMemberNames: new Set<string>(),
memberInfo: new Map(),
}),
resolveMessageRenderProps: () => ({}),
}));
vi.mock('@features/agent-graph/core/domain/buildInlineActivityEntries', () => ({
buildInlineActivityEntries: (...args: unknown[]) => buildInlineActivityEntries(...args),
getGraphLeadMemberName: () => 'team-lead',
}));
describe('GraphActivityHud', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
buildInlineActivityEntries.mockReset();
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1)
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get() {
return 296;
},
});
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get() {
return 220;
},
});
});
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
vi.unstubAllGlobals();
if (originalOffsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor);
} else {
delete (HTMLElement.prototype as { offsetWidth?: number }).offsetWidth;
}
if (originalOffsetHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeightDescriptor);
} else {
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
}
});
it('opens the member profile on the Activity tab when +N more is clicked', async () => {
const visibleMessages: InboxMessage[] = [
{
from: 'team-lead',
to: 'jack',
text: 'First',
summary: 'First',
timestamp: '2026-04-13T13:34:00.000Z',
read: false,
messageId: 'msg-1',
},
{
from: 'team-lead',
to: 'jack',
text: 'Second',
summary: 'Second',
timestamp: '2026-04-13T13:35:00.000Z',
read: false,
messageId: 'msg-2',
},
{
from: 'team-lead',
to: 'jack',
text: 'Third',
summary: 'Third',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-3',
},
];
buildInlineActivityEntries.mockReturnValue(
new Map([
[
'member:demo-team:jack',
visibleMessages.map((message, index) => ({
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id: `item-${index + 1}`,
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
})),
],
])
);
const node: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-1',
kind: 'inbox_message',
timestamp: '2026-04-13T13:36:00.000Z',
title: 'Third',
},
{
id: 'item-2',
kind: 'inbox_message',
timestamp: '2026-04-13T13:35:00.000Z',
title: 'Second',
},
{
id: 'item-3',
kind: 'inbox_message',
timestamp: '2026-04-13T13:34:00.000Z',
title: 'First',
},
{
id: 'item-4',
kind: 'inbox_message',
timestamp: '2026-04-13T13:33:00.000Z',
title: 'Older hidden',
},
],
activityOverflowCount: 1,
};
const onOpenMemberProfile = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityWorldRect: () => ({
left: 40,
top: 80,
right: 336,
bottom: 372,
width: 296,
height: 292,
}),
getCameraZoom: () => 1,
worldToScreen: (x: number, y: number) => ({ x, y }),
getNodeWorldPosition: () => ({ x: 120, y: 40 }),
getViewportSize: () => ({ width: 1200, height: 800 }),
focusNodeIds: null,
onOpenMemberProfile,
})
);
await Promise.resolve();
});
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('+1 more')
);
expect(moreButton).not.toBeUndefined();
expect(moreButton?.className).toContain('pointer-events-auto');
const shell = host.querySelector('.z-10');
expect(shell?.className).toContain('pointer-events-none');
expect(host.querySelector('[data-activity-entry-id="item-1"]')?.className).toContain(
'pointer-events-auto'
);
await act(async () => {
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onOpenMemberProfile).toHaveBeenCalledWith('jack', {
initialTab: 'activity',
initialActivityFilter: 'all',
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('pins the activity lane to the provided world rect without post-hoc repositioning', async () => {
const message: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'Latest log',
summary: 'Latest log',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-latest',
};
buildInlineActivityEntries.mockReturnValue(
new Map([
[
'member:demo-team:jack',
[
{
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id: 'item-1',
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
},
],
],
])
);
const node: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-1',
kind: 'inbox_message',
timestamp: message.timestamp,
title: 'Latest log',
},
],
activityOverflowCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const nodeWorld = { x: 320, y: 300 };
const laneRect = {
left: 120,
top: 340,
right: 416,
bottom: 632,
width: 296,
height: 292,
};
await act(async () => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityWorldRect: () => laneRect,
getCameraZoom: () => 1,
getNodeWorldPosition: () => nodeWorld,
getViewportSize: () => ({ width: 1200, height: 800 }),
worldToScreen: (x: number, y: number) => ({ x, y }),
focusNodeIds: null,
})
);
await Promise.resolve();
});
const shell = host.querySelector('.z-10');
expect(shell).not.toBeNull();
expect((shell as HTMLDivElement).style.left).toBe(`${laneRect.left}px`);
expect((shell as HTMLDivElement).style.top).toBe(`${laneRect.top}px`);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('briefly highlights newly appeared activity cards', async () => {
vi.useFakeTimers();
const firstMessage: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'Initial activity',
summary: 'Initial activity',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-initial',
};
const newMessage: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'New activity',
summary: 'New activity',
timestamp: '2026-04-13T13:37:00.000Z',
read: false,
messageId: 'msg-new',
};
const buildEntries = (items: { id: string; message: InboxMessage }[]): Map<string, unknown[]> =>
new Map([
[
'member:demo-team:jack',
items.map(({ id, message }) => ({
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id,
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
})),
],
]);
buildInlineActivityEntries.mockReturnValue(
buildEntries([{ id: 'item-initial', message: firstMessage }])
);
const baseNode: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-initial',
kind: 'inbox_message',
timestamp: firstMessage.timestamp,
title: 'Initial activity',
},
],
activityOverflowCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const renderHud = (node: GraphNode): void => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityWorldRect: () => ({
left: 40,
top: 80,
right: 336,
bottom: 372,
width: 296,
height: 292,
}),
getCameraZoom: () => 1,
worldToScreen: (x: number, y: number) => ({ x, y }),
getNodeWorldPosition: () => ({ x: 120, y: 40 }),
getViewportSize: () => ({ width: 1200, height: 800 }),
focusNodeIds: null,
})
);
};
await act(async () => {
renderHud(baseNode);
await Promise.resolve();
});
expect(host.querySelector('[data-activity-entry-id="item-initial"]')?.className).not.toContain(
'border-sky-300/70'
);
buildInlineActivityEntries.mockReturnValue(
buildEntries([
{ id: 'item-new', message: newMessage },
{ id: 'item-initial', message: firstMessage },
])
);
const updatedNode: GraphNode = {
...baseNode,
activityItems: [
{
id: 'item-new',
kind: 'inbox_message',
timestamp: newMessage.timestamp,
title: 'New activity',
},
...baseNode.activityItems!,
],
};
await act(async () => {
renderHud(updatedNode);
await Promise.resolve();
});
const newRow = host.querySelector('[data-activity-entry-id="item-new"]');
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');
await act(async () => {
root.unmount();
await Promise.resolve();
});
vi.useRealTimers();
});
});