368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
|
|
|
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
|
MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/attachments/AttachmentPreviewList', () => ({
|
|
AttachmentPreviewList: () => null,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/attachments/DropZoneOverlay', () => ({
|
|
DropZoneOverlay: () => null,
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/messages/ActionModeSelector', () => ({
|
|
ActionModeSelector: ({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) =>
|
|
React.createElement(
|
|
'select',
|
|
{
|
|
'aria-label': 'Action mode',
|
|
value,
|
|
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onChange(event.target.value),
|
|
},
|
|
React.createElement('option', { value: 'do' }, 'Do'),
|
|
React.createElement('option', { value: 'ask' }, 'Ask'),
|
|
React.createElement('option', { value: 'delegate' }, 'Delegate')
|
|
),
|
|
}));
|
|
|
|
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', { role: 'dialog' }, children),
|
|
DialogDescription: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('p', 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/label', () => ({
|
|
Label: ({
|
|
children,
|
|
htmlFor,
|
|
}: {
|
|
children: React.ReactNode;
|
|
htmlFor?: string;
|
|
}) => React.createElement('label', { htmlFor }, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/MemberSelect', () => ({
|
|
MemberSelect: ({
|
|
members,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
members: ResolvedTeamMember[];
|
|
value: string | null;
|
|
onChange: (value: string | null) => void;
|
|
}) =>
|
|
React.createElement(
|
|
'select',
|
|
{
|
|
'aria-label': 'Recipient',
|
|
value: value ?? '',
|
|
onChange: (event: React.ChangeEvent<HTMLSelectElement>) =>
|
|
onChange(event.target.value || null),
|
|
},
|
|
React.createElement('option', { value: '' }, 'Select member...'),
|
|
...members.map((member) =>
|
|
React.createElement('option', { key: member.name, value: member.name }, member.name)
|
|
)
|
|
),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/MentionableTextarea', () => ({
|
|
MentionableTextarea: ({
|
|
value,
|
|
onValueChange,
|
|
placeholder,
|
|
disabled,
|
|
cornerAction,
|
|
footerRight,
|
|
}: {
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
cornerAction?: React.ReactNode;
|
|
footerRight?: React.ReactNode;
|
|
}) =>
|
|
React.createElement(
|
|
'div',
|
|
null,
|
|
React.createElement('textarea', {
|
|
'aria-label': 'Message',
|
|
placeholder,
|
|
value,
|
|
disabled,
|
|
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
onValueChange(event.target.value),
|
|
}),
|
|
React.createElement('div', null, cornerAction),
|
|
React.createElement('div', null, footerRight)
|
|
),
|
|
}));
|
|
|
|
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),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useAttachments', () => ({
|
|
useAttachments: () => ({
|
|
attachments: [],
|
|
error: null,
|
|
canAddMore: true,
|
|
addFiles: vi.fn().mockResolvedValue(undefined),
|
|
removeAttachment: vi.fn(),
|
|
clearAttachments: vi.fn(),
|
|
clearError: vi.fn(),
|
|
handlePaste: vi.fn(),
|
|
handleDrop: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
|
|
useTaskSuggestions: () => ({ suggestions: [] }),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
|
|
useTeamSuggestions: () => ({ suggestions: [] }),
|
|
}));
|
|
|
|
vi.mock('@renderer/store', () => ({
|
|
useStore: (selector: (state: { selectedTeamData: null }) => unknown) =>
|
|
selector({ selectedTeamData: null }),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/MemberBadge', () => ({
|
|
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
|
|
}));
|
|
|
|
import { SendMessageDialog } from '@renderer/components/team/dialogs/SendMessageDialog';
|
|
|
|
const members: ResolvedTeamMember[] = [
|
|
{
|
|
name: 'team-lead',
|
|
status: 'idle',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
lastActiveAt: null,
|
|
messageCount: 0,
|
|
agentType: 'team-lead',
|
|
role: 'Team Lead',
|
|
},
|
|
{
|
|
name: 'jack',
|
|
status: 'idle',
|
|
currentTaskId: null,
|
|
taskCount: 0,
|
|
lastActiveAt: null,
|
|
messageCount: 0,
|
|
agentType: 'developer',
|
|
role: 'Developer',
|
|
},
|
|
];
|
|
|
|
function renderDialog(props: Partial<React.ComponentProps<typeof SendMessageDialog>> = {}) {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onClose = vi.fn();
|
|
const onSend = vi.fn<React.ComponentProps<typeof SendMessageDialog>['onSend']>();
|
|
|
|
act(() => {
|
|
root.render(
|
|
React.createElement(SendMessageDialog, {
|
|
open: true,
|
|
teamName: 'team-a',
|
|
members,
|
|
defaultRecipient: 'jack',
|
|
isTeamAlive: true,
|
|
sending: false,
|
|
sendError: null,
|
|
sendWarning: null,
|
|
sendDebugDetails: null,
|
|
lastResult: null,
|
|
onClose,
|
|
onSend,
|
|
...props,
|
|
})
|
|
);
|
|
});
|
|
|
|
return { host, root, onClose, 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 setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
if (!setter) {
|
|
throw new Error('HTMLTextAreaElement value setter not found');
|
|
}
|
|
setter.call(textarea, value);
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
describe('SendMessageDialog', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
localStorage.clear();
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('preserves draft text when async send fails', async () => {
|
|
let rejectSend: (error: Error) => void = () => undefined;
|
|
const failedSend = new Promise<SendMessageResult | void>((_resolve, reject) => {
|
|
rejectSend = reject;
|
|
});
|
|
const onSend = vi.fn(() => failedSend);
|
|
const { host, root } = renderDialog({ onSend, teamName: 'team-runtime-failed' });
|
|
|
|
const textarea = host.querySelector('textarea[aria-label="Message"]') as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setTextareaValue(textarea, 'Please verify the OpenCode delivery path');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(getSendButton(host).disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
getSendButton(host).click();
|
|
await Promise.resolve();
|
|
});
|
|
expect(onSend).toHaveBeenCalledWith(
|
|
'jack',
|
|
'Please verify the OpenCode delivery path',
|
|
'Please verify the OpenCode delivery path',
|
|
undefined,
|
|
'do',
|
|
[]
|
|
);
|
|
|
|
await act(async () => {
|
|
rejectSend(new Error('runtime delivery failed'));
|
|
await failedSend.catch(() => undefined);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(textarea.value).toBe('Please verify the OpenCode delivery path');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('preserves draft text when OpenCode runtime delivery fails after persistence', async () => {
|
|
const onSend = vi.fn<React.ComponentProps<typeof SendMessageDialog>['onSend']>(() =>
|
|
Promise.resolve({
|
|
deliveredToInbox: true,
|
|
messageId: 'm-opencode-failed',
|
|
runtimeDelivery: {
|
|
providerId: 'opencode',
|
|
attempted: true,
|
|
delivered: false,
|
|
reason: 'runtime_delivery_failed',
|
|
},
|
|
})
|
|
);
|
|
const { host, root } = renderDialog({ onSend });
|
|
|
|
const textarea = host.querySelector('textarea[aria-label="Message"]') as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setTextareaValue(textarea, 'Keep this text if live delivery fails');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await act(async () => {
|
|
getSendButton(host).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(textarea.value).toBe('Keep this text if live delivery fails');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows live delivery warning without closing the dialog', async () => {
|
|
const warning =
|
|
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
|
const { host, root, onClose } = renderDialog({
|
|
sendWarning: warning,
|
|
sendDebugDetails: {
|
|
messageId: 'm-opencode-1',
|
|
providerId: 'opencode',
|
|
delivered: false,
|
|
responsePending: false,
|
|
responseState: 'failed',
|
|
ledgerStatus: 'failed',
|
|
acceptanceUnknown: false,
|
|
reason: 'runtime_delivery_failed',
|
|
diagnostics: ['runtime_delivery_failed'],
|
|
},
|
|
});
|
|
|
|
expect(host.textContent).toContain(warning);
|
|
expect(host.textContent).not.toContain('ledgerStatus');
|
|
expect(host.textContent).not.toContain('runtime_delivery_failed');
|
|
|
|
const detailsButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
|
button.textContent?.includes('Details')
|
|
);
|
|
expect(detailsButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
detailsButton?.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('ledgerStatus');
|
|
expect(host.textContent).toContain('responseState');
|
|
expect(host.textContent).toContain('runtime_delivery_failed');
|
|
expect(host.textContent).toContain('Send Message');
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|