agent-ecosystem/test/renderer/components/sidebar/GlobalTaskList.test.ts

339 lines
10 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GlobalTask } from '../../../../src/shared/types';
interface StoreState {
globalTasks: GlobalTask[];
globalTasksLoading: boolean;
globalTasksInitialized: boolean;
fetchAllTasks: ReturnType<typeof vi.fn>;
softDeleteTask: ReturnType<typeof vi.fn>;
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
viewMode: 'flat' | 'grouped';
repositoryGroups: {
id: string;
name: string;
totalSessions: number;
worktrees: { path: string }[];
}[];
teams: { teamName: string; displayName: string }[];
}
const storeState = {} as StoreState;
const toggleCollapsedGroup = vi.fn();
const taskLocalState = {
isPinned: vi.fn(() => false),
isArchived: vi.fn(() => false),
getRenamedSubject: vi.fn(() => undefined),
togglePin: vi.fn(),
toggleArchive: vi.fn(),
renameTask: vi.fn(),
};
const storeListeners = new Set<() => void>();
function notifyStoreUpdate(): void {
storeListeners.forEach((l) => l());
}
vi.mock('../../../../src/renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useState, useEffect } = require('react') as typeof import('react');
const [, setVersion] = useState(0);
useEffect(() => {
const listener = () => setVersion((v) => v + 1);
storeListeners.add(listener);
return () => {
storeListeners.delete(listener);
};
}, []);
return selector(storeState);
},
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: <T>(selector: T) => selector,
}));
vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({
confirm: vi.fn(() => Promise.resolve(true)),
}));
vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({
useCollapsedGroups: () => ({
isCollapsed: () => false,
toggle: toggleCollapsedGroup,
}),
}));
vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({
useTaskLocalState: () => taskLocalState,
}));
vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({
AnimatedHeightReveal: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
TaskContextMenu: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
SidebarTaskItem: ({ task, hideProjectName }: { task: GlobalTask; hideProjectName?: boolean }) =>
React.createElement(
'div',
{
'data-testid': 'sidebar-task-item',
'data-hide-project-name': hideProjectName ? 'true' : 'false',
},
task.subject
),
}));
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({
TaskFiltersPopover: () => null,
}));
vi.mock('../../../../src/renderer/components/ui/popover', () => ({
Popover: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
PopoverTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
PopoverContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
Archive: Icon,
ArrowUpDown: Icon,
Check: Icon,
ChevronDown: Icon,
ChevronRight: Icon,
Folder: Icon,
ListTodo: Icon,
Pin: Icon,
Search: Icon,
X: Icon,
};
});
import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function findButton(host: HTMLElement, label: string): HTMLButtonElement | null {
return (
Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === label
) ?? null
);
}
function visibleSubjects(host: HTMLElement): string[] {
return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map(
(node) => node.textContent ?? ''
);
}
function makeTask(index: number, overrides: Partial<GlobalTask> = {}): GlobalTask {
const timestamp = String(60 - index).padStart(2, '0');
return {
id: `task-${index}`,
displayId: `task${index}`,
teamName: 'alpha-team',
teamDisplayName: 'Alpha Team',
subject: `Task ${index}`,
description: '',
status: 'in_progress',
owner: 'alice',
createdAt: `2026-04-18T10:${timestamp}:00.000Z`,
updatedAt: `2026-04-18T10:${timestamp}:00.000Z`,
reviewState: 'none',
reviewNotes: [],
blockedBy: [],
blocks: [],
comments: [],
attachments: [],
workIntervals: [],
kanbanColumnId: null,
projectPath: '/workspace/hookplex',
...overrides,
} as GlobalTask;
}
describe('GlobalTaskList project grouping', () => {
beforeEach(() => {
storeState.globalTasks = [];
storeState.globalTasksLoading = false;
storeState.globalTasksInitialized = true;
storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined));
storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined));
storeState.projects = [];
storeState.viewMode = 'flat';
storeState.repositoryGroups = [];
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
toggleCollapsedGroup.mockReset();
taskLocalState.isPinned.mockClear();
taskLocalState.isArchived.mockClear();
taskLocalState.getRenamedSubject.mockClear();
taskLocalState.togglePin.mockClear();
taskLocalState.toggleArchive.mockClear();
taskLocalState.renameTask.mockClear();
localStorage.clear();
localStorage.setItem('sidebarTasksGrouping', 'project');
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
storeListeners.clear();
});
it('shows five tasks first, then expands and collapses with Show more and Show less', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']);
expect(findButton(host, 'Show more')).not.toBeNull();
expect(findButton(host, 'Show less')).toBeNull();
await act(async () => {
findButton(host, 'Show more')?.click();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual([
'Task 1',
'Task 2',
'Task 3',
'Task 4',
'Task 5',
'Task 6',
]);
expect(findButton(host, 'Show less')).not.toBeNull();
await act(async () => {
findButton(host, 'Show less')?.click();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']);
expect(findButton(host, 'Show less')).toBeNull();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('hides project labels in task cards when grouped by project', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = [makeTask(1), makeTask(2)];
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(
Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map((node) =>
node.getAttribute('data-hide-project-name')
)
).toEqual(['true', 'true']);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
await act(async () => {
findButton(host, 'Show more')?.click();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toHaveLength(10);
expect(findButton(host, 'Show less')).not.toBeNull();
storeState.globalTasks = [
makeTask(0, {
id: 'task-new',
displayId: 'task-new',
subject: 'Task 0',
createdAt: '2026-04-18T11:00:00.000Z',
updatedAt: '2026-04-18T11:00:00.000Z',
}),
...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)),
];
await act(async () => {
notifyStoreUpdate();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toHaveLength(10);
expect(visibleSubjects(host)).toEqual([
'Task 0',
'Task 1',
'Task 2',
'Task 3',
'Task 4',
'Task 5',
'Task 6',
'Task 7',
'Task 8',
'Task 9',
]);
expect(visibleSubjects(host)).not.toContain('Task 10');
expect(findButton(host, 'Show more')).not.toBeNull();
expect(findButton(host, 'Show less')).not.toBeNull();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});