agent-ecosystem/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx
2026-05-18 01:57:16 +03:00

1583 lines
48 KiB
TypeScript

import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { TooltipProvider } from '@renderer/components/ui/tooltip';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TEAM_CHANGES_LOAD_TIMEOUT_MS } from '../teamChangesLoadTimeout';
import { TeamChangesSection } from '../TeamChangesSection';
import { type TeamChangeSummaryState, useTeamChangesSummaries } from '../useTeamChangesSummaries';
import type {
TaskChangeSetV2,
TeamTaskChangeSummariesResponse,
TeamTaskChangeSummaryRequest,
TeamTaskWithKanban,
} from '@shared/types';
const hoisted = vi.hoisted(() => ({
fetchConfig: vi.fn(),
getTeamTaskChangeSummaries: vi.fn(),
recordTaskChangePresence: vi.fn(),
setSelectedTeamTaskChangePresence: vi.fn(),
}));
vi.mock('@renderer/api', () => ({
api: {
review: {
getTeamTaskChangeSummaries: hoisted.getTeamTaskChangeSummaries,
},
},
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appConfig: { general: { theme: 'dark' } },
configLoading: false,
fetchConfig: hoisted.fetchConfig,
memberActivityMetaByTeam: {},
recordTaskChangePresence: hoisted.recordTaskChangePresence,
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
selectedTeamData: null,
selectedTeamName: undefined,
teamDataCacheByName: {},
}),
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function createDeferred<T>(): 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 task(overrides: Partial<TeamTaskWithKanban> = {}): TeamTaskWithKanban {
return {
id: 'task-1',
subject: 'Task 1',
status: 'completed',
owner: 'alice',
createdAt: '2026-05-10T10:00:00.000Z',
updatedAt: '2026-05-10T10:00:00.000Z',
changePresence: 'unknown',
...overrides,
};
}
function changedTasks(count: number): TeamTaskWithKanban[] {
return Array.from({ length: count }, (_, index) =>
task({
id: `changed-${index}`,
subject: `Changed ${index}`,
changePresence: 'has_changes',
updatedAt: `2026-05-10T10:${String(index).padStart(2, '0')}:00.000Z`,
})
);
}
function changeSet(taskId = 'task-1'): TaskChangeSetV2 {
return {
teamName: 'team-a',
taskId,
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'high',
computedAt: '2026-05-10T10:00:00.000Z',
scope: {
taskId,
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '2026-05-10T10:00:00.000Z',
endTimestamp: '2026-05-10T10:01:00.000Z',
toolUseIds: [],
filePaths: [],
confidence: { tier: 1, label: 'high', reason: 'test' },
},
warnings: [],
};
}
function fileChange(
overrides: Partial<TaskChangeSetV2['files'][number]> = {}
): TaskChangeSetV2['files'][number] {
return {
filePath: '/repo/src/app.ts',
relativePath: 'src/app.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: false,
...overrides,
};
}
function response(summary: TaskChangeSetV2 = changeSet()): TeamTaskChangeSummariesResponse {
return {
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: [{ taskId: 'task-1', changeSet: summary }],
};
}
function responseForRequests(
requests: TeamTaskChangeSummaryRequest[]
): TeamTaskChangeSummariesResponse {
return {
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: requests.map((request) => ({
taskId: request.taskId,
changeSet: changeSet(request.taskId),
})),
};
}
function malformedLegacyChangeSet(): TaskChangeSetV2 {
return {
...changeSet(),
files: undefined,
scope: undefined,
totalFiles: 1,
warnings: ['legacy warning'],
} as unknown as TaskChangeSetV2;
}
function malformedUnknownChangeSet(): TaskChangeSetV2 {
return {
...changeSet(),
confidence: 'fallback',
files: undefined,
warnings: undefined,
} as unknown as TaskChangeSetV2;
}
function malformedResponse(): TeamTaskChangeSummariesResponse {
return {
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: undefined,
} as unknown as TeamTaskChangeSummariesResponse;
}
function malformedItemResponse(): TeamTaskChangeSummariesResponse {
return {
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: [
{
taskId: ' task-1 ',
changeSet: 'not-a-change-set',
error: { message: 'not a string' },
},
],
} as unknown as TeamTaskChangeSummariesResponse;
}
function incompleteChangeSetResponse(): TeamTaskChangeSummariesResponse {
return {
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: [
{
taskId: 'task-1',
changeSet: {
teamName: 'team-a',
taskId: 'task-1',
files: [],
warnings: [],
confidence: 'high',
},
},
],
} as unknown as TeamTaskChangeSummariesResponse;
}
function quietNoLogChangeSet(): TaskChangeSetV2 {
return {
...changeSet(),
confidence: 'fallback',
scope: {
taskId: 'task-1',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
};
}
function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
confidence: 'low',
files: [fileChange()],
totalFiles: 1,
totalLinesAdded: 1,
});
}
function intervalScopedFileResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
confidence: 'medium',
files: [
fileChange({
filePath: '/repo/791/calculator.js',
relativePath: '791/calculator.js',
}),
],
totalFiles: 1,
totalLinesAdded: 162,
scope: {
...changeSet().scope,
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
},
warnings: ['Task start boundary missing - scoped by persisted workIntervals timestamps.'],
});
}
function warningFileResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
files: [fileChange()],
totalFiles: 1,
totalLinesAdded: 1,
warnings: ['Unexpected ledger warning.'],
});
}
function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
confidence: 'low',
files: [{} as TaskChangeSetV2['files'][number]],
totalFiles: 1,
totalLinesAdded: 1,
});
}
interface HookSnapshot {
loading: boolean;
refreshing: boolean;
error: string | null;
badgeCount: number | null;
summariesByTaskId: Record<string, TeamChangeSummaryState>;
refresh: () => void;
}
const HookHarness = ({
tasks,
sectionOpen = true,
onSnapshot,
}: {
tasks: TeamTaskWithKanban[];
sectionOpen?: boolean;
onSnapshot: (snapshot: HookSnapshot) => void;
}): null => {
const state = useTeamChangesSummaries({
teamName: 'team-a',
tasks,
sectionOpen,
});
React.useEffect(() => {
onSnapshot({
loading: state.loading,
refreshing: state.refreshing,
error: state.error,
badgeCount: state.badgeCount,
summariesByTaskId: state.summariesByTaskId,
refresh: state.refresh,
});
}, [
onSnapshot,
state.badgeCount,
state.error,
state.loading,
state.refresh,
state.refreshing,
state.summariesByTaskId,
]);
return null;
};
describe('useTeamChangesSummaries', () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
vi.clearAllMocks();
});
it('does not keep initial loading stuck when tasks change during an active request', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
});
expect(snapshots.at(-1)?.loading).toBe(true);
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
onSnapshot,
})
);
});
await act(async () => {
first.resolve(response());
await first.promise;
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(true);
});
it('does not cache a stale active response when a newer task snapshot is queued', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
});
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
onSnapshot,
})
);
});
await act(async () => {
first.resolve(
response({
...changeSet(),
files: [fileChange({ filePath: '/repo/src/stale.ts', relativePath: 'src/stale.ts' })],
totalFiles: 1,
totalLinesAdded: 1,
})
);
await first.promise;
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
await act(async () => {
second.resolve(response());
await second.promise;
});
expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'task-1',
expect.any(Object),
'no_changes'
);
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'task-1',
'no_changes'
);
});
it('retries the initial load after React StrictMode effect remount replay', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
React.StrictMode,
null,
React.createElement(HookHarness, { tasks: [task()], onSnapshot })
)
);
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => {
first.resolve(response());
await first.promise;
});
expect(snapshots.at(-1)?.loading).toBe(true);
await act(async () => {
second.resolve(response());
await second.promise;
});
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.taskId).toBe('task-1');
});
it('clears initial loading and reports an error when the batch request times out', async () => {
vi.useFakeTimers();
try {
hoisted.getTeamTaskChangeSummaries.mockReturnValue(new Promise(() => undefined));
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
});
expect(snapshots.at(-1)?.loading).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(TEAM_CHANGES_LOAD_TIMEOUT_MS);
});
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(false);
expect(snapshots.at(-1)?.error).toBe('Team changes request timed out. Refresh to try again.');
} finally {
vi.useRealTimers();
}
});
it('does not immediately run a queued refresh after a request failure', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries.mockReturnValue(first.promise);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
});
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
onSnapshot,
})
);
});
await act(async () => {
first.reject(new Error('boom'));
await first.promise.catch(() => undefined);
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(false);
expect(snapshots.at(-1)?.error).toBe('boom');
});
it('clears loading and reports an error for a malformed batch response', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedResponse());
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
await Promise.resolve();
});
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(false);
expect(snapshots.at(-1)?.error).toBe('Team changes response was malformed.');
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
});
it('normalizes malformed batch response items before storing summaries', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedItemResponse());
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
await Promise.resolve();
});
expect(snapshots.at(-1)?.error).toBeNull();
expect(snapshots.at(-1)?.summariesByTaskId['task-1']).toEqual({
taskId: 'task-1',
changeSet: null,
});
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
});
it('does not cache presence for incomplete change summaries', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(incompleteChangeSetResponse());
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
await Promise.resolve();
});
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull();
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
});
it('caches has_changes for low-confidence summaries with safe file details', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
await Promise.resolve();
});
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.confidence).toBe('low');
expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'task-1',
expect.any(Object),
'has_changes'
);
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'task-1',
'has_changes'
);
});
it('does not cache presence for summaries with unsafe file details', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(invalidFileSummaryResponse());
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
await Promise.resolve();
});
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull();
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
});
it('renders legacy malformed summaries without crashing the section', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedLegacyChangeSet()));
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('legacy warning');
expect(container.textContent).toContain(
'The change summary reported one file without safe review details.'
);
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('does not render active no-log summaries as Changes warnings', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ status: 'in_progress', changePresence: 'needs_attention' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('No file changes recorded');
expect(container.textContent).not.toContain('No log files found for this task.');
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'task-1',
'unknown'
);
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('hides work-interval scoping advisories in the compact Changes list when files are present', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(intervalScopedFileResponse());
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ status: 'completed', owner: 'jack' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('791/calculator.js');
expect(container.textContent).not.toContain(
'Task start boundary missing - scoped by persisted workIntervals timestamps.'
);
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('keeps unrelated file warnings visible in the compact Changes list', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(warningFileResponse());
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ status: 'completed' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('src/app.ts');
expect(container.textContent).toContain('Unexpected ledger warning.');
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('does not clear completed task presence from an uncertain empty summary', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [task({ status: 'completed', changePresence: 'needs_attention' })],
onSnapshot: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
});
it('clears stale selected presence for newly created pending tasks without logs', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [task({ status: 'pending', changePresence: 'needs_attention' })],
onSnapshot: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'task-1',
'unknown'
);
});
it('uses the first duplicate task id when deciding whether to clear stale presence', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [
task({ status: 'completed', changePresence: 'needs_attention' }),
task({ status: 'in_progress', changePresence: 'needs_attention' }),
],
onSnapshot: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
});
it('does not clear task presence from malformed unknown summaries', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedUnknownChangeSet()));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [task({ status: 'in_progress', changePresence: 'needs_attention' })],
onSnapshot: vi.fn(),
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
});
it('shows the closed-section counter only after the background count load resolves', async () => {
const deferred = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
expect(container.textContent).toContain('Changes');
expect(container.textContent).not.toContain('0');
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
await act(async () => {
deferred.resolve(response());
await deferred.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('0');
});
it('loads the closed-section counter without rendering full change rows', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain('1');
expect(container.textContent).not.toContain('src/app.ts');
});
it('loads staged open batches without repeating successful tasks', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
const third = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
expect(firstRequests).toHaveLength(3);
expect(firstRequests[0]?.taskId).toBe('changed-29');
await act(async () => {
first.resolve(responseForRequests(firstRequests));
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
expect(secondRequests).toHaveLength(9);
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(3);
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(true);
expect(
secondRequests.some((request) =>
firstRequests.some((firstRequest) => firstRequest.taskId === request.taskId)
)
).toBe(false);
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(3);
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[2][1] as TeamTaskChangeSummaryRequest[];
expect(thirdRequests).toHaveLength(18);
expect(
thirdRequests.some((request) =>
[...firstRequests, ...secondRequests].some(
(previousRequest) => previousRequest.taskId === request.taskId
)
)
).toBe(false);
await act(async () => {
third.resolve(responseForRequests(thirdRequests));
await third.promise;
await Promise.resolve();
});
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
expect(snapshots.at(-1)?.loading).toBe(false);
});
it('does not skip failed first-pass tasks from the queued full refresh', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
const third = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
expect(firstRequests).toHaveLength(3);
const failedTaskId = firstRequests[0]?.taskId ?? '';
await act(async () => {
first.resolve({
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: firstRequests.map((request, index) =>
index === 0
? { taskId: request.taskId, changeSet: null, error: 'first pass failed' }
: { taskId: request.taskId, changeSet: changeSet(request.taskId) }
),
});
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
expect(secondRequests).toHaveLength(9);
expect(secondRequests.some((request) => request.taskId === failedTaskId)).toBe(true);
expect(secondRequests.some((request) => request.taskId === firstRequests[1]?.taskId)).toBe(
false
);
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(3);
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[2][1] as TeamTaskChangeSummaryRequest[];
expect(thirdRequests).toHaveLength(19);
await act(async () => {
third.resolve(responseForRequests(thirdRequests));
await third.promise;
await Promise.resolve();
});
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
});
it('does not apply staged in-flight results after the section closes', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
first.resolve(responseForRequests(firstRequests));
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(3);
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [],
sectionOpen: false,
onSnapshot,
})
);
await Promise.resolve();
});
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(false);
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
});
it('starts force refresh from the first staged batch after a completed staged load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
const third = createDeferred<TeamTaskChangeSummariesResponse>();
const fourth = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise)
.mockReturnValueOnce(fourth.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
first.resolve(responseForRequests(firstRequests));
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[2][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
third.resolve(responseForRequests(thirdRequests));
await third.promise;
await Promise.resolve();
});
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
await act(async () => {
snapshots.at(-1)?.refresh();
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(4);
const refreshRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[3][1] as TeamTaskChangeSummaryRequest[];
expect(refreshRequests).toHaveLength(3);
expect(refreshRequests.map((request) => request.taskId)).toEqual(
firstRequests.map((request) => request.taskId)
);
expect(refreshRequests.every((request) => request.options?.forceFresh === true)).toBe(true);
await act(async () => {
fourth.resolve(responseForRequests(refreshRequests));
await fourth.promise;
await Promise.resolve();
});
});
it('runs a queued closed counter refresh when tasks change during an active count load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
await act(async () => {
first.resolve(lowConfidenceFileResponse());
await first.promise;
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => {
second.resolve(response());
await second.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('0');
});
it('starts the full load immediately when opening during an active count load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
});
expect(container.textContent).toContain('Loading changes...');
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => {
first.resolve(lowConfidenceFileResponse());
await first.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('Loading changes...');
expect(container.textContent).not.toContain('src/app.ts');
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => {
second.resolve(lowConfidenceFileResponse());
await second.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('src/app.ts');
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('opens the task popup from summary header and keeps diff on the review action', async () => {
const taskItem = task({ changePresence: 'has_changes' });
const onOpenTask = vi.fn();
const onViewChanges = vi.fn();
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [taskItem],
memberColorMap: new Map([['alice', 'blue']]),
onOpenTask,
onViewChanges,
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.querySelector('img')).not.toBeNull();
const openTaskButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open task Task 1"]'
);
expect(openTaskButton).not.toBeNull();
await act(async () => {
openTaskButton?.click();
});
expect(onOpenTask).toHaveBeenCalledWith(taskItem);
expect(onViewChanges).not.toHaveBeenCalled();
const reviewTaskDiffButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Review task diff"]'
);
expect(reviewTaskDiffButton).not.toBeNull();
await act(async () => {
reviewTaskDiffButton?.click();
});
expect(onViewChanges).toHaveBeenCalledWith('task-1');
onViewChanges.mockClear();
const fileRow = container.querySelector<HTMLElement>('[role="button"][title="src/app.ts"]');
expect(fileRow).not.toBeNull();
await act(async () => {
fileRow?.click();
});
expect(onViewChanges).toHaveBeenCalledTimes(1);
expect(onViewChanges).toHaveBeenCalledWith('task-1', '/repo/src/app.ts');
onViewChanges.mockClear();
const reviewFileDiffButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Review diff"]'
);
expect(reviewFileDiffButton).not.toBeNull();
await act(async () => {
reviewFileDiffButton?.click();
});
expect(onViewChanges).toHaveBeenCalledTimes(1);
expect(onViewChanges).toHaveBeenCalledWith('task-1', '/repo/src/app.ts');
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
});