agent-ecosystem/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts
2026-05-08 09:58:57 +03:00

656 lines
19 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
getTaskChanges: vi.fn(),
updateTaskFields: vi.fn(),
recordTaskChangePresence: vi.fn(),
setSelectedTeamTaskChangePresence: vi.fn(),
setTaskNeedsClarification: vi.fn(),
getTaskAttachmentData: vi.fn(),
}));
vi.mock('@renderer/api', () => ({
api: {
review: {
getTaskChanges: hoisted.getTaskChanges,
},
},
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
updateTaskFields: hoisted.updateTaskFields,
recordTaskChangePresence: hoisted.recordTaskChangePresence,
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
setTaskNeedsClarification: hoisted.setTaskNeedsClarification,
getTaskAttachmentData: hoisted.getTaskAttachmentData,
}),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/hooks/useViewportCommentRead', () => ({
useViewportCommentRead: () => ({ registerComment: vi.fn(), flush: vi.fn() }),
}));
vi.mock('@renderer/services/commentReadStorage', () => ({
getLegacyCutoff: () => 0,
getReadCommentIds: () => new Set<string>(),
}));
vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
CollapsibleTeamSection: ({
title,
children,
defaultOpen = true,
onOpenChange,
badge,
headerExtra,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
badge?: React.ReactNode;
headerExtra?: React.ReactNode;
}) => {
const [open, setOpen] = React.useState(defaultOpen);
React.useEffect(() => {
onOpenChange?.(open);
}, [open, onOpenChange]);
return React.createElement(
'section',
null,
React.createElement(
'button',
{
type: 'button',
onClick: () => setOpen((value) => !value),
},
title,
badge !== undefined
? React.createElement('span', { 'data-testid': `section-badge-${title}` }, badge)
: null,
headerExtra
? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra)
: null
),
(title === 'Changes' || title === 'Workflow History') && open
? React.createElement('div', null, children)
: null
);
},
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogDescription: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogHeader: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogTitle: ({ children }: { children: React.ReactNode }) =>
React.createElement('h2', null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
type = 'button',
}: {
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}) => React.createElement('button', { type, disabled, onClick }, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: Record<string, unknown>) => React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/MemberSelect', () => ({
MemberSelect: () => React.createElement('div', null),
}));
vi.mock('@renderer/components/ui/ExpandableContent', () => ({
ExpandableContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tiptap', () => ({
TiptapEditor: () => React.createElement('div', null),
}));
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content),
}));
vi.mock('@renderer/components/team/MemberBadge', () => ({
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
}));
vi.mock('@renderer/components/team/editor/FileIcon', () => ({
FileIcon: () => React.createElement('span', null),
}));
vi.mock('@renderer/components/common/OngoingIndicator', () => ({
OngoingIndicator: () => React.createElement('span', null),
}));
vi.mock('@renderer/components/team/attachments/ImageLightbox', () => ({
ImageLightbox: () => null,
LightboxLockProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('@renderer/components/team/attachments/SourceMessageAttachments', () => ({
SourceMessageAttachments: () => null,
}));
vi.mock('@renderer/components/team/taskLogs/TaskLogsPanel', () => ({
TaskLogsPanel: () => null,
}));
import { TaskDetailDialog } from '@renderer/components/team/dialogs/TaskDetailDialog';
import type { TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeTask(id: string): TeamTaskWithKanban {
return {
id,
displayId: id,
subject: `Task ${id}`,
description: '',
owner: 'alice',
reviewer: '',
status: 'in_progress',
changePresence: 'unknown',
comments: [],
attachments: [],
blockedBy: [],
blocks: [],
workIntervals: [{ startedAt: '2026-04-20T10:00:00.000Z' }],
historyEvents: [],
createdAt: '2026-04-20T09:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
} as unknown as TeamTaskWithKanban;
}
function makeSummary(taskId: string): TaskChangeSetV2 {
return {
teamName: 'team-a',
taskId,
files: [
{
filePath: `/repo/src/${taskId}.ts`,
relativePath: `src/${taskId}.ts`,
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'high',
computedAt: '2026-04-20T10:05:00.000Z',
scope: {
taskId,
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '2026-04-20T10:00:00.000Z',
endTimestamp: '2026-04-20T10:05:00.000Z',
toolUseIds: ['tool-1'],
filePaths: [`/repo/src/${taskId}.ts`],
confidence: { tier: 1, label: 'high', reason: 'ledger' },
},
warnings: [],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: `fingerprint-${taskId}`,
},
};
}
function clickChangesSection(host: HTMLElement): void {
const button = [...host.querySelectorAll('button')].find(
(candidate) => candidate.textContent?.startsWith('Changes') === true
);
if (!button) {
throw new Error('Changes section button not found');
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
describe('TaskDetailDialog changes summary loading', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('shows a zero attachments count in the attachments section header', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task: { ...makeTask('task-empty-attachments'), workIntervals: [] },
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="section-badge-Attachments"]')?.textContent).toBe('0');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not drop a new task changes request while another task summary is still in flight', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const first = deferred<TaskChangeSetV2>();
const second = deferred<TaskChangeSetV2>();
hoisted.getTaskChanges
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise);
const taskA: TeamTaskWithKanban = { ...makeTask('task-a'), changePresence: 'has_changes' };
const taskB: TeamTaskWithKanban = { ...makeTask('task-b'), changePresence: 'has_changes' };
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const baseProps = {
open: true,
variant: 'team' as const,
teamName: 'team-a',
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
};
await act(async () => {
root.render(React.createElement(TaskDetailDialog, { ...baseProps, task: taskA }));
await Promise.resolve();
});
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-a',
expect.objectContaining({ summaryOnly: true })
);
await act(async () => {
root.render(React.createElement(TaskDetailDialog, { ...baseProps, task: taskB }));
await Promise.resolve();
});
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-b',
expect.objectContaining({ summaryOnly: true })
);
await act(async () => {
root.render(React.createElement(TaskDetailDialog, { ...baseProps, task: taskA }));
await Promise.resolve();
});
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
await act(async () => {
first.resolve(makeSummary('task-a'));
await Promise.resolve();
});
expect(host.textContent).toContain('src/task-a.ts');
expect(host.textContent).not.toContain('src/task-b.ts');
await act(async () => {
second.resolve(makeSummary('task-b'));
await Promise.resolve();
});
expect(host.textContent).toContain('src/task-a.ts');
expect(host.textContent).not.toContain('src/task-b.ts');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the changes section lazy-loadable when the task needs attention', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hoisted.getTaskChanges.mockResolvedValueOnce({
...makeSummary('task-attention'),
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'low',
warnings: ['No file changes were recorded for this task.'],
});
const task: TeamTaskWithKanban = {
...makeTask('task-attention'),
changePresence: 'needs_attention',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
})
);
await Promise.resolve();
});
expect(
[...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes')
).toBe(true);
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-attention',
expect.objectContaining({ summaryOnly: true })
);
expect(host.textContent).toContain('No file changes recorded');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('preloads the changes summary after 1.5 seconds and shows header loading state', async () => {
vi.useFakeTimers();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const request = deferred<TaskChangeSetV2>();
hoisted.getTaskChanges.mockImplementationOnce(() => request.promise);
const task: TeamTaskWithKanban = { ...makeTask('task-autoload'), changePresence: 'unknown' };
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
})
);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1_499);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-autoload',
expect.objectContaining({ summaryOnly: true, forceFresh: false })
);
expect(host.querySelector('[data-testid="section-badge-Changes"]')).toBeNull();
expect(
host.querySelector('[data-testid="section-extra-Changes"] .animate-spin')
).not.toBeNull();
await act(async () => {
request.resolve(makeSummary('task-autoload'));
await Promise.resolve();
});
expect(host.querySelector('[data-testid="section-badge-Changes"]')?.textContent).toBe('1');
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain('src/task-autoload.ts');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the changes section visible for pending tasks and loads without a review handler', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hoisted.getTaskChanges.mockResolvedValueOnce(makeSummary('task-pending'));
const task: TeamTaskWithKanban = {
...makeTask('task-pending'),
status: 'pending',
changePresence: 'unknown',
workIntervals: [],
} as unknown as TeamTaskWithKanban;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
})
);
await Promise.resolve();
});
expect(
[...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes')
).toBe(true);
await act(async () => {
clickChangesSection(host);
await Promise.resolve();
});
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
'team-a',
'task-pending',
expect.objectContaining({ summaryOnly: true })
);
expect(host.textContent).toContain('src/task-pending.ts');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows total and per-transition implementation time in workflow history', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z'));
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const task: TeamTaskWithKanban = {
...makeTask('task-duration'),
workIntervals: [
{
startedAt: '2026-04-20T10:00:00.000Z',
completedAt: '2026-04-20T10:02:30.000Z',
},
{ startedAt: '2026-04-20T10:05:00.000Z' },
],
historyEvents: [
{
id: 'event-created',
timestamp: '2026-04-20T09:59:00.000Z',
type: 'task_created',
status: 'pending',
actor: 'lead',
},
{
id: 'event-started',
timestamp: '2026-04-20T10:00:00.000Z',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
actor: 'lead',
},
{
id: 'event-completed',
timestamp: '2026-04-20T10:02:31.000Z',
type: 'status_changed',
from: 'in_progress',
to: 'completed',
actor: 'alice',
},
{
id: 'event-restarted',
timestamp: '2026-04-20T10:05:00.000Z',
type: 'status_changed',
from: 'completed',
to: 'in_progress',
actor: 'lead',
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Workflow History');
expect(host.textContent).toContain('Work time 5m 00s');
const workflowButton = [...host.querySelectorAll('button')].find(
(button) => button.textContent?.startsWith('Workflow History') === true
);
if (!workflowButton) {
throw new Error('Workflow History section button not found');
}
await act(async () => {
workflowButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('2m 30s');
expect(host.textContent).toContain('running 2m 30s');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});