agent-ecosystem/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx
2026-05-30 17:43:08 +03:00

733 lines
21 KiB
TypeScript

/* eslint-disable @typescript-eslint/naming-convention -- vi.mock component exports must stay PascalCase. */
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
const draftHarness = vi.hoisted(() => {
const initialState = {
text: 'hello teammate',
chips: [] as unknown[],
attachments: [] as unknown[],
actionMode: 'do',
isSaved: true,
isLoaded: true,
};
const state = { ...initialState };
const methods = {
addChip: vi.fn(),
addFiles: vi.fn().mockResolvedValue(undefined),
clearAttachmentError: vi.fn(),
clearAttachments: vi.fn(),
clearDraft: vi.fn(() => {
state.text = '';
state.chips = [];
state.attachments = [];
}),
finalizePendingSendClear: vi.fn(),
handleDrop: vi.fn(),
handlePaste: vi.fn(),
hideDraftForPendingSend: vi.fn((_content: { text: string }) => {
state.text = '';
state.chips = [];
state.attachments = [];
}),
removeAttachment: vi.fn(),
removeChip: vi.fn(),
restoreDraft: vi.fn((content: { text: string }) => {
state.text = content.text;
}),
setActionMode: vi.fn((mode: string) => {
state.actionMode = mode;
}),
setText: vi.fn((text: string) => {
state.text = text;
}),
};
return {
methods,
reset: () => {
Object.assign(state, initialState);
for (const method of Object.values(methods)) {
method.mockClear();
}
},
state,
};
});
const provisioningHarness = vi.hoisted(() => {
const state = {
active: false,
};
return {
reset: () => {
state.active = false;
},
state,
};
});
interface SuggestionHookOptions {
enabled?: boolean;
}
const suggestionHarness = vi.hoisted(() => {
const state = {
taskOptions: [] as SuggestionHookOptions[],
teamOptions: [] as SuggestionHookOptions[],
};
return {
reset: () => {
state.taskOptions = [];
state.teamOptions = [];
},
state,
};
});
const storeHarness = vi.hoisted(() => {
const state = {
crossTeamTargets: [] as {
teamName: string;
displayName: string;
description?: string;
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}[],
};
const methods = {
// Returns a resolved Promise<boolean> to match the store contract: the composer
// chains `.then()` on this to clear its dedup ref and retry on failure.
fetchCrossTeamTargets: vi.fn().mockResolvedValue(true),
fetchSkillsCatalog: vi.fn(),
};
return {
methods,
reset: () => {
state.crossTeamTargets = [];
methods.fetchCrossTeamTargets.mockClear();
methods.fetchSkillsCatalog.mockClear();
},
state,
};
});
vi.mock('@renderer/api', () => ({
api: {
teams: {
aliveList: vi.fn(() => new Promise<string[]>(() => undefined)),
},
},
}));
vi.mock('@renderer/components/team/attachments/AttachmentPreviewList', () => ({
AttachmentPreviewList: () => null,
}));
vi.mock('@renderer/components/team/attachments/DropZoneOverlay', () => ({
DropZoneOverlay: () => null,
}));
vi.mock('@renderer/components/team/MemberBadge', () => ({
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
}));
vi.mock('@renderer/components/team/messages/ActionModeSelector', () => ({
ActionModeSelector: ({ disabled }: { disabled?: boolean }) =>
React.createElement('button', { disabled, type: 'button' }, 'Do'),
}));
vi.mock('@renderer/components/team/messages/OpenCodeDeliveryWarning', () => ({
OpenCodeDeliveryWarning: () => null,
}));
vi.mock('@renderer/components/ui/MentionableTextarea', () => {
const MockMentionableTextarea = React.forwardRef<
HTMLTextAreaElement,
{
value: string;
disabled?: boolean;
cornerAction?: React.ReactNode;
cornerActionLeft?: React.ReactNode;
footerRight?: React.ReactNode;
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>;
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>;
}
>(({ value, disabled, cornerAction, cornerActionLeft, footerRight, onBlur, onFocus }, ref) =>
React.createElement(
'div',
null,
React.createElement('textarea', {
'aria-label': 'Message',
disabled,
onBlur,
onFocus,
readOnly: true,
ref,
value,
}),
React.createElement('div', null, cornerActionLeft),
React.createElement('div', null, cornerAction),
React.createElement('div', null, footerRight)
)
);
MockMentionableTextarea.displayName = 'MockMentionableTextarea';
return { MentionableTextarea: MockMentionableTextarea };
});
vi.mock('@renderer/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
PopoverContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
PopoverTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, 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('div', null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
/* eslint-enable @typescript-eslint/naming-convention -- End PascalCase vi.mock component exports. */
vi.mock('@renderer/hooks/useComposerDraft', () => ({
useComposerDraft: () => ({
text: draftHarness.state.text,
setText: draftHarness.methods.setText,
chips: draftHarness.state.chips,
addChip: draftHarness.methods.addChip,
removeChip: draftHarness.methods.removeChip,
attachments: draftHarness.state.attachments,
attachmentError: null,
canAddMore: true,
addFiles: draftHarness.methods.addFiles,
removeAttachment: draftHarness.methods.removeAttachment,
clearAttachments: draftHarness.methods.clearAttachments,
clearAttachmentError: draftHarness.methods.clearAttachmentError,
handlePaste: draftHarness.methods.handlePaste,
handleDrop: draftHarness.methods.handleDrop,
actionMode: draftHarness.state.actionMode,
setActionMode: draftHarness.methods.setActionMode,
isSaved: draftHarness.state.isSaved,
isLoaded: draftHarness.state.isLoaded,
clearDraft: draftHarness.methods.clearDraft,
hideDraftForPendingSend: draftHarness.methods.hideDraftForPendingSend,
finalizePendingSendClear: draftHarness.methods.finalizePendingSendClear,
restoreDraft: draftHarness.methods.restoreDraft,
}),
}));
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
useTaskSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => {
suggestionHarness.state.taskOptions.push(options);
return { suggestions: [] };
},
}));
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
useTeamSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => {
suggestionHarness.state.teamOptions.push(options);
return { suggestions: [] };
},
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
crossTeamTargets: storeHarness.state.crossTeamTargets,
fetchCrossTeamTargets: storeHarness.methods.fetchCrossTeamTargets,
fetchSkillsCatalog: storeHarness.methods.fetchSkillsCatalog,
selectedTeamData: null,
selectedTeamName: null,
skillsProjectCatalogByProjectPath: {},
skillsUserCatalog: [],
}),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
isTeamProvisioningActive: () => provisioningHarness.state.active,
}));
import { MessageComposer } from './MessageComposer';
const members: ResolvedTeamMember[] = [
{
agentType: 'developer',
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
name: 'alice',
role: 'Developer',
status: 'idle',
taskCount: 0,
},
{
agentType: 'developer',
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
name: 'bob',
role: 'Developer',
status: 'idle',
taskCount: 0,
},
];
function renderComposer(overrides: Partial<React.ComponentProps<typeof MessageComposer>> = {}): {
host: HTMLDivElement;
render: (next?: Partial<React.ComponentProps<typeof MessageComposer>>) => void;
root: ReturnType<typeof createRoot>;
onSend: ReturnType<typeof vi.fn>;
} {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onSend = vi.fn();
const baseProps: React.ComponentProps<typeof MessageComposer> = {
teamName: 'team-alpha',
members,
isTeamAlive: true,
sending: false,
sendError: null,
sendWarning: null,
sendDebugDetails: null,
lastResult: null,
onSend,
};
const render = (next: Partial<React.ComponentProps<typeof MessageComposer>> = {}): void => {
act(() => {
root.render(React.createElement(MessageComposer, { ...baseProps, ...overrides, ...next }));
});
};
render();
return { host, render, root, onSend };
}
function getSendButton(host: HTMLElement): HTMLButtonElement {
const button = Array.from(host.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === 'Send'
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error('Send button not found');
}
return button;
}
function getTextarea(host: HTMLElement): HTMLTextAreaElement {
const textarea = host.querySelector('textarea[aria-label="Message"]');
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error('Message textarea not found');
}
return textarea;
}
function getButtonContainingText(host: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(host.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes(text)
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Button containing "${text}" not found`);
}
return button;
}
describe('MessageComposer pending send lifecycle', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
draftHarness.reset();
provisioningHarness.reset();
suggestionHarness.reset();
storeHarness.reset();
});
afterEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('hides the submitted draft when sending starts and finalizes it on success', () => {
const { host, onSend, render, root } = renderComposer();
act(() => {
getSendButton(host).click();
});
expect(onSend).toHaveBeenCalledWith(
'alice',
'hello teammate',
'hello teammate',
undefined,
'do',
[]
);
expect(draftHarness.methods.hideDraftForPendingSend).not.toHaveBeenCalled();
render({ sending: true });
expect(draftHarness.methods.hideDraftForPendingSend).toHaveBeenCalledWith(
expect.objectContaining({
text: 'hello teammate',
actionMode: 'do',
pendingSendId: expect.any(String) as string,
})
);
expect(draftHarness.state.text).toBe('');
expect(getTextarea(host).disabled).toBe(false);
expect(getSendButton(host).disabled).toBe(true);
render({ sending: false });
expect(draftHarness.methods.finalizePendingSendClear).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
text: 'hello teammate',
actionMode: 'do',
pendingSendId: expect.any(String) as string,
})
);
expect(draftHarness.methods.restoreDraft).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it('restores the submitted draft when sending fails after the optimistic hide', () => {
const { host, render, root } = renderComposer();
act(() => {
getSendButton(host).click();
});
render({ sending: true });
render({ sending: false, sendError: 'runtime failed' });
expect(draftHarness.methods.restoreDraft).toHaveBeenCalledWith(
expect.objectContaining({ text: 'hello teammate' })
);
expect(draftHarness.state.text).toBe('hello teammate');
expect(draftHarness.methods.finalizePendingSendClear).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it('does not consume a new pending send before a sending transition is observed', () => {
const { host, render, root } = renderComposer({
lastResult: { deliveredToInbox: true, messageId: 'previous-message' },
});
act(() => {
getSendButton(host).click();
});
render({
lastResult: { deliveredToInbox: true, messageId: 'previous-message' },
sending: false,
});
expect(draftHarness.methods.hideDraftForPendingSend).not.toHaveBeenCalled();
expect(draftHarness.methods.finalizePendingSendClear).not.toHaveBeenCalled();
expect(draftHarness.methods.clearDraft).not.toHaveBeenCalled();
expect(draftHarness.state.text).toBe('hello teammate');
act(() => {
root.unmount();
});
});
it('clears the draft when a fast send completes before a sending render is observed', () => {
const previousResult = { deliveredToInbox: true, messageId: 'previous-message' };
const { host, render, root } = renderComposer({ lastResult: previousResult });
act(() => {
getSendButton(host).click();
});
render({
lastResult: { deliveredToInbox: true, messageId: 'new-message' },
sending: false,
});
expect(draftHarness.methods.clearDraft).toHaveBeenCalledOnce();
expect(draftHarness.methods.hideDraftForPendingSend).not.toHaveBeenCalled();
expect(draftHarness.methods.finalizePendingSendClear).not.toHaveBeenCalled();
expect(draftHarness.state.text).toBe('');
act(() => {
root.unmount();
});
});
it('restores a revision request into the composer', () => {
const revisionRequest = {
requestId: 'rev-1',
originalMessageId: 'msg-123',
originalText: 'incomplete message',
recipient: 'bob',
actionMode: 'ask' as const,
};
const { render, root } = renderComposer();
render({ revisionRequest });
expect(draftHarness.methods.restoreDraft).toHaveBeenCalledWith({
text: 'incomplete message',
chips: [],
attachments: [],
actionMode: 'ask',
});
expect(draftHarness.state.text).toBe('incomplete message');
expect(draftHarness.state.actionMode).toBe('ask');
act(() => {
root.unmount();
});
});
it('wraps the next send as a correction for the revised message', () => {
const revisionRequest = {
requestId: 'rev-1',
originalMessageId: 'msg-123',
originalText: 'incomplete message',
recipient: 'bob',
actionMode: 'ask' as const,
};
const { host, onSend, render, root } = renderComposer();
render({ revisionRequest });
render({ revisionRequest });
act(() => {
getSendButton(host).click();
});
expect(onSend).toHaveBeenCalledWith(
'bob',
[
'Correction for my previous message (MessageId: msg-123).',
'',
'Please use this corrected version instead:',
'',
'incomplete message',
].join('\n'),
'Correction for MessageId: msg-123',
undefined,
'ask',
[]
);
act(() => {
root.unmount();
});
});
it('cancels revision mode without clearing the draft', () => {
const onRevisionCancel = vi.fn();
const revisionRequest = {
requestId: 'rev-1',
originalMessageId: 'msg-123',
originalText: 'incomplete message',
recipient: 'bob',
actionMode: 'ask' as const,
};
const { host, render, root } = renderComposer({ onRevisionCancel });
render({ revisionRequest });
render({ revisionRequest });
act(() => {
getButtonContainingText(host, 'Cancel').click();
});
expect(onRevisionCancel).toHaveBeenCalledOnce();
expect(draftHarness.methods.clearDraft).not.toHaveBeenCalled();
expect(draftHarness.state.text).toBe('incomplete message');
act(() => {
root.unmount();
});
});
it('keeps revision mode when sending the correction fails', () => {
const onRevisionComplete = vi.fn();
const revisionRequest = {
requestId: 'rev-1',
originalMessageId: 'msg-123',
originalText: 'incomplete message',
recipient: 'bob',
actionMode: 'ask' as const,
};
const { host, render, root } = renderComposer({ onRevisionComplete });
render({ revisionRequest });
render({ revisionRequest });
draftHarness.methods.restoreDraft.mockClear();
act(() => {
getSendButton(host).click();
});
render({ revisionRequest, sending: true });
render({ revisionRequest, sending: false, sendError: 'runtime failed' });
expect(onRevisionComplete).not.toHaveBeenCalled();
expect(draftHarness.methods.restoreDraft).toHaveBeenCalledWith(
expect.objectContaining({ text: 'incomplete message' })
);
act(() => {
root.unmount();
});
});
it('keeps send enabled when stale provisioning state remains after the team is alive', () => {
provisioningHarness.state.active = true;
const { host, onSend, root } = renderComposer({ isTeamAlive: true });
expect(getSendButton(host).disabled).toBe(false);
act(() => {
getSendButton(host).click();
});
expect(onSend).toHaveBeenCalledOnce();
act(() => {
root.unmount();
});
});
it('keeps send disabled while provisioning before the team is alive', () => {
provisioningHarness.state.active = true;
const { host, onSend, root } = renderComposer({ isTeamAlive: false });
expect(getSendButton(host).disabled).toBe(true);
act(() => {
getSendButton(host).click();
});
expect(onSend).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it('returns focus to the textarea after sending', () => {
const { host, root } = renderComposer();
const sendButton = getSendButton(host);
const textarea = getTextarea(host);
sendButton.focus();
expect(document.activeElement).toBe(sendButton);
act(() => {
sendButton.click();
});
expect(document.activeElement).toBe(textarea);
act(() => {
root.unmount();
});
});
it('returns focus to the textarea after selecting a recipient member', () => {
const { host, root } = renderComposer();
const bobButton = getButtonContainingText(host, 'bob');
const textarea = getTextarea(host);
bobButton.focus();
expect(document.activeElement).toBe(bobButton);
act(() => {
bobButton.click();
});
expect(document.activeElement).toBe(textarea);
act(() => {
root.unmount();
});
});
it('returns focus to the textarea after selecting a cross-team recipient', () => {
storeHarness.state.crossTeamTargets = [
{
teamName: 'team-beta',
displayName: 'Beta Team',
},
];
const { host, root } = renderComposer({ onCrossTeamSend: vi.fn() });
const betaTeamButton = getButtonContainingText(host, 'Beta Team');
const textarea = getTextarea(host);
betaTeamButton.focus();
expect(document.activeElement).toBe(betaTeamButton);
act(() => {
betaTeamButton.click();
});
expect(document.activeElement).toBe(textarea);
act(() => {
root.unmount();
});
});
it('defers expensive mention data until the matching trigger is typed', () => {
draftHarness.state.text = '';
const { host, render, root } = renderComposer();
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false);
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false);
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled();
act(() => {
getTextarea(host).focus();
});
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false);
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false);
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled();
draftHarness.state.text = '#';
render();
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(true);
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false);
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
draftHarness.state.text = '@';
render();
expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false);
expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(true);
expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled();
draftHarness.state.text = '/';
render();
expect(storeHarness.methods.fetchSkillsCatalog).toHaveBeenCalledTimes(1);
expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
});