620 lines
19 KiB
TypeScript
620 lines
19 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { TeamChangeEvent } from '../../../../../src/shared/types';
|
|
import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types';
|
|
|
|
const apiState = {
|
|
getTaskLogStream: vi.fn<
|
|
(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>
|
|
>(),
|
|
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
|
|
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
|
};
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
teams: {
|
|
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
|
|
apiState.getTaskLogStream(...args),
|
|
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
|
apiState.onTeamChange(...args),
|
|
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
|
|
apiState.setTaskLogStreamTracking(...args),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({
|
|
MemberExecutionLog: ({
|
|
memberName,
|
|
chunks,
|
|
}: {
|
|
memberName?: string;
|
|
chunks: { id: string }[];
|
|
}) =>
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'member-execution-log' },
|
|
`${memberName ?? 'lead'}:${chunks.length}`
|
|
),
|
|
}));
|
|
|
|
import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection';
|
|
|
|
function flushMicrotasks(): Promise<void> {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
function buildParticipant(key: string, label: string) {
|
|
return {
|
|
key,
|
|
label,
|
|
role: 'member' as const,
|
|
isLead: false,
|
|
isSidechain: true,
|
|
};
|
|
}
|
|
|
|
function buildSegment(args: {
|
|
id: string;
|
|
participantKey: string;
|
|
memberName: string;
|
|
startTimestamp: string;
|
|
endTimestamp: string;
|
|
}) {
|
|
return {
|
|
id: args.id,
|
|
participantKey: args.participantKey,
|
|
actor: {
|
|
memberName: args.memberName,
|
|
role: 'member' as const,
|
|
sessionId: `${args.memberName}-session-${args.id}`,
|
|
agentId: `${args.memberName}-agent`,
|
|
isSidechain: true,
|
|
},
|
|
startTimestamp: args.startTimestamp,
|
|
endTimestamp: args.endTimestamp,
|
|
chunks: [{ id: `chunk-${args.id}`, chunkType: 'user', rawMessages: [] }] as never,
|
|
};
|
|
}
|
|
|
|
describe('TaskLogStreamSection', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
apiState.getTaskLogStream.mockReset();
|
|
apiState.onTeamChange.mockReset();
|
|
apiState.setTaskLogStreamTracking.mockReset();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('renders empty state when the stream is absent', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
apiState.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [],
|
|
defaultFilter: 'all',
|
|
segments: [],
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Task Log Stream');
|
|
expect(host.textContent).toContain('No task log stream yet');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('shows participant chips and filters the visible segments', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
apiState.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [
|
|
{
|
|
key: 'member:tom',
|
|
label: 'tom',
|
|
role: 'member',
|
|
isLead: false,
|
|
isSidechain: true,
|
|
},
|
|
{
|
|
key: 'member:alice',
|
|
label: 'alice',
|
|
role: 'member',
|
|
isLead: false,
|
|
isSidechain: true,
|
|
},
|
|
],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
{
|
|
id: 'segment-tom-1',
|
|
participantKey: 'member:tom',
|
|
actor: {
|
|
memberName: 'tom',
|
|
role: 'member',
|
|
sessionId: 'session-tom-1',
|
|
agentId: 'agent-tom',
|
|
isSidechain: true,
|
|
},
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
chunks: [{ id: 'chunk-tom-1', chunkType: 'user', rawMessages: [] }] as never,
|
|
},
|
|
{
|
|
id: 'segment-alice-1',
|
|
participantKey: 'member:alice',
|
|
actor: {
|
|
memberName: 'alice',
|
|
role: 'member',
|
|
sessionId: 'session-alice-1',
|
|
agentId: 'agent-alice',
|
|
isSidechain: true,
|
|
},
|
|
startTimestamp: '2026-04-12T16:02:00.000Z',
|
|
endTimestamp: '2026-04-12T16:03:00.000Z',
|
|
chunks: [{ id: 'chunk-alice-1', chunkType: 'user', rawMessages: [] }] as never,
|
|
},
|
|
{
|
|
id: 'segment-tom-2',
|
|
participantKey: 'member:tom',
|
|
actor: {
|
|
memberName: 'tom',
|
|
role: 'member',
|
|
sessionId: 'session-tom-2',
|
|
agentId: 'agent-tom',
|
|
isSidechain: true,
|
|
},
|
|
startTimestamp: '2026-04-12T16:04:00.000Z',
|
|
endTimestamp: '2026-04-12T16:05:00.000Z',
|
|
chunks: [{ id: 'chunk-tom-2', chunkType: 'user', rawMessages: [] }] as never,
|
|
},
|
|
],
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(host.textContent).toContain('All');
|
|
expect(host.textContent).toContain('tom');
|
|
expect(host.textContent).toContain('alice');
|
|
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(3);
|
|
|
|
const buttons = [...host.querySelectorAll('button')];
|
|
const tomButton = buttons.find((button) => button.textContent?.trim() === 'tom');
|
|
expect(tomButton).toBeDefined();
|
|
|
|
await act(async () => {
|
|
tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
const logs = [...host.querySelectorAll('[data-testid="member-execution-log"]')].map(
|
|
(node) => node.textContent
|
|
);
|
|
expect(logs).toEqual(['tom:1', 'tom:1']);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('describes OpenCode runtime fallback when the stream source is projected from runtime logs', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
apiState.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [buildParticipant('member:alice', 'alice')],
|
|
defaultFilter: 'member:alice',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'segment-alice-1',
|
|
participantKey: 'member:alice',
|
|
memberName: 'alice',
|
|
startTimestamp: '2026-04-21T10:00:00.000Z',
|
|
endTimestamp: '2026-04-21T10:01:00.000Z',
|
|
}),
|
|
],
|
|
source: 'opencode_runtime_fallback',
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Task-scoped OpenCode runtime logs projected');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('describes OpenCode marker-based fallback when runtime projection matched task tools', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
apiState.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [buildParticipant('member:alice', 'alice')],
|
|
defaultFilter: 'member:alice',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'segment-alice-marker',
|
|
participantKey: 'member:alice',
|
|
memberName: 'alice',
|
|
startTimestamp: '2026-04-21T10:00:00.000Z',
|
|
endTimestamp: '2026-04-21T10:01:00.000Z',
|
|
}),
|
|
],
|
|
source: 'opencode_runtime_fallback',
|
|
runtimeProjection: {
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 2,
|
|
fallbackReason: 'task_tool_markers',
|
|
markerMatchCount: 1,
|
|
markerSpanCount: 2,
|
|
},
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(host.textContent).toContain('matched task tool markers');
|
|
expect(host.textContent).toContain('across 2 spans');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('honors a participant default filter from the stream response', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
|
apiState.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [
|
|
{
|
|
key: 'member:tom',
|
|
label: 'tom',
|
|
role: 'member',
|
|
isLead: false,
|
|
isSidechain: false,
|
|
},
|
|
],
|
|
defaultFilter: 'member:tom',
|
|
segments: [
|
|
{
|
|
id: 'segment-tom-1',
|
|
participantKey: 'member:tom',
|
|
actor: {
|
|
memberName: 'tom',
|
|
role: 'lead',
|
|
sessionId: 'session-tom-1',
|
|
isSidechain: false,
|
|
},
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
chunks: [{ id: 'chunk-tom-1', chunkType: 'ai', rawMessages: [] }] as never,
|
|
},
|
|
],
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(1);
|
|
expect(host.textContent).toContain('tom:1');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('live-refreshes on matching task-log changes and preserves the selected participant filter', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.useFakeTimers();
|
|
|
|
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
|
|
apiState.onTeamChange.mockImplementation((callback) => {
|
|
handler = callback;
|
|
return () => {
|
|
handler = null;
|
|
};
|
|
});
|
|
|
|
apiState.getTaskLogStream
|
|
.mockResolvedValueOnce({
|
|
participants: [
|
|
buildParticipant('member:tom', 'tom'),
|
|
buildParticipant('member:alice', 'alice'),
|
|
],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'tom-1',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
}),
|
|
buildSegment({
|
|
id: 'alice-1',
|
|
participantKey: 'member:alice',
|
|
memberName: 'alice',
|
|
startTimestamp: '2026-04-12T16:02:00.000Z',
|
|
endTimestamp: '2026-04-12T16:03:00.000Z',
|
|
}),
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
participants: [
|
|
buildParticipant('member:tom', 'tom'),
|
|
buildParticipant('member:alice', 'alice'),
|
|
],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'tom-1',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
}),
|
|
buildSegment({
|
|
id: 'alice-1',
|
|
participantKey: 'member:alice',
|
|
memberName: 'alice',
|
|
startTimestamp: '2026-04-12T16:02:00.000Z',
|
|
endTimestamp: '2026-04-12T16:03:00.000Z',
|
|
}),
|
|
buildSegment({
|
|
id: 'tom-2',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:04:00.000Z',
|
|
endTimestamp: '2026-04-12T16:05:00.000Z',
|
|
}),
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
participants: [
|
|
buildParticipant('member:tom', 'tom'),
|
|
buildParticipant('member:alice', 'alice'),
|
|
],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'tom-1',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
}),
|
|
buildSegment({
|
|
id: 'tom-2',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:04:00.000Z',
|
|
endTimestamp: '2026-04-12T16:05:00.000Z',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
const tomButton = [...host.querySelectorAll('button')].find(
|
|
(button) => button.textContent?.trim() === 'tom'
|
|
);
|
|
expect(tomButton).toBeDefined();
|
|
|
|
await act(async () => {
|
|
tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(
|
|
[...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent)
|
|
).toEqual(['tom:1']);
|
|
|
|
expect(handler).toBeTypeOf('function');
|
|
|
|
await act(async () => {
|
|
handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' });
|
|
vi.advanceTimersByTime(400);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' });
|
|
vi.advanceTimersByTime(400);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' });
|
|
vi.advanceTimersByTime(400);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
[...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent)
|
|
).toEqual(['tom:1', 'tom:1']);
|
|
|
|
await act(async () => {
|
|
handler?.(null, { teamName: 'demo', type: 'log-source-change' });
|
|
vi.advanceTimersByTime(400);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(3);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('does not subscribe to live refresh when live mode is disabled', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
|
|
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
|
apiState.getTaskLogStream.mockResolvedValueOnce({
|
|
participants: [buildParticipant('member:tom', 'tom')],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'tom-1',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(TaskLogStreamSection, {
|
|
teamName: 'demo',
|
|
taskId: 'task-a',
|
|
liveEnabled: false,
|
|
})
|
|
);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
|
expect(apiState.onTeamChange).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
|
|
it('revalidates once when the task leaves in-progress state', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
|
|
apiState.getTaskLogStream
|
|
.mockResolvedValueOnce({
|
|
participants: [buildParticipant('member:tom', 'tom')],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'tom-1',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
}),
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
participants: [buildParticipant('member:tom', 'tom')],
|
|
defaultFilter: 'all',
|
|
segments: [
|
|
buildSegment({
|
|
id: 'tom-1',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:00:00.000Z',
|
|
endTimestamp: '2026-04-12T16:01:00.000Z',
|
|
}),
|
|
buildSegment({
|
|
id: 'tom-2',
|
|
participantKey: 'member:tom',
|
|
memberName: 'tom',
|
|
startTimestamp: '2026-04-12T16:02:00.000Z',
|
|
endTimestamp: '2026-04-12T16:03:00.000Z',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(TaskLogStreamSection, {
|
|
teamName: 'demo',
|
|
taskId: 'task-a',
|
|
taskStatus: 'in_progress',
|
|
liveEnabled: true,
|
|
})
|
|
);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(TaskLogStreamSection, {
|
|
teamName: 'demo',
|
|
taskId: 'task-a',
|
|
taskStatus: 'completed',
|
|
liveEnabled: false,
|
|
})
|
|
);
|
|
await flushMicrotasks();
|
|
});
|
|
|
|
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
|
|
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(2);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await flushMicrotasks();
|
|
});
|
|
});
|
|
});
|