first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
|
||||
import Chat from '../components/Chat.vue';
|
||||
import type { ChatMessage } from '../types/messages';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../components/GetStarted.vue', () => ({
|
||||
default: { name: 'GetStarted', template: '<div>GetStarted</div>' },
|
||||
}));
|
||||
|
||||
vi.mock('../components/GetStartedFooter.vue', () => ({
|
||||
default: { name: 'GetStartedFooter', template: '<div>GetStartedFooter</div>' },
|
||||
}));
|
||||
|
||||
vi.mock('../components/Input.vue', () => ({
|
||||
default: {
|
||||
name: 'Input',
|
||||
template:
|
||||
'<div data-test-id="chat-input" @arrow-key-down="$emit(\'arrowKeyDown\', $event)" @escape-key-down="$emit(\'escapeKeyDown\', $event)"></div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/Layout.vue', () => ({
|
||||
default: {
|
||||
name: 'Layout',
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessagesList.vue', () => ({
|
||||
default: {
|
||||
name: 'MessagesList',
|
||||
template: '<div>MessagesList</div>',
|
||||
props: ['messages'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('virtual:icons/mdi/close', () => ({
|
||||
default: { name: 'IconClose' },
|
||||
}));
|
||||
|
||||
const mockChatStore = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
startNewSession: vi.fn(),
|
||||
messages: [] as ChatMessage[],
|
||||
};
|
||||
|
||||
const mockOptions = {
|
||||
mode: 'window' as const,
|
||||
showWindowCloseButton: true,
|
||||
showWelcomeScreen: false,
|
||||
};
|
||||
|
||||
vi.mock('@n8n/chat/composables', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
useChat: () => ({
|
||||
messages: { value: mockChatStore.messages },
|
||||
initialize: mockChatStore.initialize,
|
||||
startNewSession: mockChatStore.startNewSession,
|
||||
currentSessionId: { value: 'test-session' },
|
||||
}),
|
||||
useOptions: () => ({ options: mockOptions }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/event-buses', () => ({
|
||||
chatEventBus: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Chat', () => {
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockChatStore.messages = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
describe('arrow key navigation', () => {
|
||||
beforeEach(() => {
|
||||
// Set up messages for testing navigation
|
||||
mockChatStore.messages = [
|
||||
{
|
||||
id: '1',
|
||||
text: 'First message',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
text: 'Bot response',
|
||||
sender: 'bot',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
text: 'Second message',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
text: 'Third message',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it('should navigate to previous message on ArrowUp', async () => {
|
||||
wrapper = mount(Chat);
|
||||
|
||||
// Trigger ArrowUp
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
|
||||
});
|
||||
|
||||
it('should navigate through message history on multiple ArrowUp presses', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// First ArrowUp - should get most recent message
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
|
||||
|
||||
// Second ArrowUp - should get second most recent
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Second message');
|
||||
|
||||
// Third ArrowUp - should get oldest
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Second message' });
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'First message');
|
||||
});
|
||||
|
||||
it('should not go beyond the oldest message', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// Navigate to the end
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Second message' });
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Try to go beyond the oldest message
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'First message' });
|
||||
|
||||
// Should still emit blur/focus, but not setInputValue
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('blurInput');
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
|
||||
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalledWith(
|
||||
'setInputValue',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should navigate forward on ArrowDown', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// Navigate back first
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Navigate forward
|
||||
await input.vm.$emit('arrowKeyDown', {
|
||||
key: 'ArrowDown',
|
||||
currentInputValue: 'Second message',
|
||||
});
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
|
||||
});
|
||||
|
||||
it('should clear input when navigating past the newest message', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// Navigate back
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Navigate forward to clear
|
||||
await input.vm.$emit('arrowKeyDown', {
|
||||
key: 'ArrowDown',
|
||||
currentInputValue: 'Third message',
|
||||
});
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', '');
|
||||
});
|
||||
|
||||
it('should handle empty message history gracefully', async () => {
|
||||
mockChatStore.messages = [];
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
|
||||
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only include user messages in navigation', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// First ArrowUp should skip bot messages
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
|
||||
|
||||
// Second ArrowUp should also skip bot messages
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Second message');
|
||||
});
|
||||
|
||||
it('should reset history index when messageSent event is emitted', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// Navigate back in history
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
|
||||
|
||||
// Get the messageSent callback
|
||||
const messageSentCallback = vi
|
||||
.mocked(chatEventBus.on)
|
||||
.mock.calls.find((call) => call[0] === 'messageSent')?.[1];
|
||||
|
||||
expect(messageSentCallback).toBeDefined();
|
||||
|
||||
// Trigger messageSent event
|
||||
if (messageSentCallback) {
|
||||
messageSentCallback();
|
||||
}
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// After reset, ArrowUp should start from the beginning again
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
|
||||
});
|
||||
|
||||
it('should preserve current input when starting navigation', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
// Start navigation with some input
|
||||
await input.vm.$emit('arrowKeyDown', {
|
||||
key: 'ArrowUp',
|
||||
currentInputValue: 'My partial message',
|
||||
});
|
||||
|
||||
// Navigate down to restore
|
||||
await input.vm.$emit('arrowKeyDown', {
|
||||
key: 'ArrowDown',
|
||||
currentInputValue: 'Third message',
|
||||
});
|
||||
await input.vm.$emit('arrowKeyDown', {
|
||||
key: 'ArrowDown',
|
||||
currentInputValue: 'Third message',
|
||||
});
|
||||
|
||||
// Should restore the original input
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith(
|
||||
'setInputValue',
|
||||
'My partial message',
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit blur and focus events during navigation', async () => {
|
||||
wrapper = mount(Chat);
|
||||
const input = wrapper.findComponent({ name: 'Input' });
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
|
||||
|
||||
// Should blur before setting value
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('blurInput');
|
||||
// Should focus after setting value
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import Input from '../components/Input.vue';
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useFileDialog: vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => 'mock-uuid-123'),
|
||||
}));
|
||||
|
||||
vi.mock('virtual:icons/mdi/paperclip', () => ({
|
||||
default: { name: 'IconPaperclip' },
|
||||
}));
|
||||
|
||||
vi.mock('virtual:icons/mdi/send', () => ({
|
||||
default: { name: 'IconSend' },
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/composables', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
useChat: () => ({
|
||||
waitingForResponse: { value: false },
|
||||
blockUserInput: { value: false },
|
||||
currentSessionId: { value: 'session-123' },
|
||||
messages: { value: [] },
|
||||
sendMessage: vi.fn(),
|
||||
ws: null,
|
||||
}),
|
||||
useOptions: () => ({
|
||||
options: {
|
||||
disabled: { value: false },
|
||||
allowFileUploads: { value: true },
|
||||
allowedFilesMimeTypes: { value: 'image/*,text/*' },
|
||||
webhookUrl: 'https://example.com/webhook',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/event-buses', () => ({
|
||||
chatEventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./ChatFile.vue', () => ({
|
||||
default: { name: 'ChatFile' },
|
||||
}));
|
||||
|
||||
describe('ChatInput', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let wrapper: VueWrapper<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - mock WebSocket
|
||||
global.WebSocket = vi.fn().mockImplementation(
|
||||
() =>
|
||||
({
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
}) as unknown as WebSocket,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the component with default props', () => {
|
||||
wrapper = mount(Input);
|
||||
|
||||
expect(wrapper.find('textarea').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test-id="chat-input"]').exists()).toBe(true);
|
||||
expect(wrapper.find('.chat-input-send-button').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('applies custom placeholder', () => {
|
||||
wrapper = mount(Input, {
|
||||
props: {
|
||||
placeholder: 'customPlaceholder',
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = wrapper.find('textarea');
|
||||
expect(textarea.attributes('placeholder')).toBe('customPlaceholder');
|
||||
});
|
||||
|
||||
it('updates input value when typing', async () => {
|
||||
const textarea = wrapper.find('textarea');
|
||||
|
||||
await textarea.setValue('Hello world');
|
||||
|
||||
expect(wrapper.vm.input).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('does not submit on Shift+Enter', async () => {
|
||||
const textarea = wrapper.find('textarea');
|
||||
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit');
|
||||
|
||||
await textarea.setValue('Test message');
|
||||
await textarea.trigger('keydown.enter', { shiftKey: true });
|
||||
|
||||
expect(onSubmitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets up WebSocket connection with execution ID', () => {
|
||||
const executionId = 'exec-123';
|
||||
|
||||
wrapper.vm.setupWebsocketConnection(executionId);
|
||||
|
||||
expect(global.WebSocket).toHaveBeenCalledWith(expect.stringContaining('sessionId=session-123'));
|
||||
expect(global.WebSocket).toHaveBeenCalledWith(expect.stringContaining('executionId=exec-123'));
|
||||
});
|
||||
|
||||
it('handles WebSocket messages correctly', async () => {
|
||||
const mockWs = {
|
||||
send: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
};
|
||||
wrapper.vm.chatStore.ws = mockWs;
|
||||
wrapper.vm.waitingForChatResponse = true;
|
||||
|
||||
await wrapper.vm.respondToChatNode(mockWs, 'Test message');
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(expect.stringContaining('"chatInput":"Test message"'));
|
||||
});
|
||||
|
||||
it('handles empty file list gracefully', () => {
|
||||
wrapper.vm.files = null;
|
||||
|
||||
expect(() => wrapper.vm.attachFiles()).not.toThrow();
|
||||
expect(wrapper.vm.attachFiles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('prevents submit when disabled', async () => {
|
||||
const submitButton = wrapper.find('.chat-input-send-button');
|
||||
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(wrapper.vm.isSubmitting).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useChat } from '@n8n/chat/composables';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
|
||||
import MessageActions from '../components/MessageActions.vue';
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nTooltip: {
|
||||
name: 'N8nTooltip',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
N8nIcon: {
|
||||
name: 'N8nIcon',
|
||||
template: '<div :icon="icon" :size="size" @click="$emit(\'click\')"></div>',
|
||||
props: ['icon', 'size'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/composables', () => ({
|
||||
useChat: vi.fn(() => ({
|
||||
sendMessage: vi.fn(),
|
||||
})),
|
||||
useOptions: () => ({
|
||||
options: {
|
||||
enableMessageActions: true,
|
||||
},
|
||||
}),
|
||||
useI18n: () => ({
|
||||
t: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/event-buses', () => ({
|
||||
chatEventBus: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MessageActions', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let wrapper: VueWrapper<any>;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: '1',
|
||||
text: 'Hello, world!',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render message actions for user messages when enabled', () => {
|
||||
wrapper = mount(MessageActions, {
|
||||
props: {
|
||||
message: userMessage,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('.message-actions').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should call sendMessage when repost icon is clicked', async () => {
|
||||
const mockSendMessage = vi.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
||||
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
|
||||
|
||||
wrapper = mount(MessageActions, {
|
||||
props: {
|
||||
message: userMessage,
|
||||
},
|
||||
});
|
||||
|
||||
const repostIcon = wrapper.find('[icon="redo-2"]');
|
||||
expect(repostIcon.exists()).toBe(true);
|
||||
|
||||
await repostIcon.trigger('click');
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('Hello, world!', []);
|
||||
});
|
||||
|
||||
it('should emit setInputValue event when copy to input icon is clicked', async () => {
|
||||
wrapper = mount(MessageActions, {
|
||||
props: {
|
||||
message: userMessage,
|
||||
},
|
||||
});
|
||||
|
||||
const copyIcon = wrapper.find('[icon="files"]');
|
||||
expect(copyIcon.exists()).toBe(true);
|
||||
|
||||
await copyIcon.trigger('click');
|
||||
|
||||
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Hello, world!');
|
||||
});
|
||||
|
||||
it('should handle messages with files when reposting', async () => {
|
||||
const mockSendMessage = vi.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
||||
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
|
||||
|
||||
const messageWithFiles: ChatMessage = {
|
||||
id: '3',
|
||||
text: 'Message with files',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
files: [new File(['test'], 'test.txt')],
|
||||
};
|
||||
|
||||
wrapper = mount(MessageActions, {
|
||||
props: {
|
||||
message: messageWithFiles,
|
||||
},
|
||||
});
|
||||
|
||||
const repostIcon = wrapper.find('[icon="redo-2"]');
|
||||
await repostIcon.trigger('click');
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('Message with files', [expect.any(File)]);
|
||||
});
|
||||
|
||||
it('should not repost empty messages', async () => {
|
||||
const mockSendMessage = vi.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
||||
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
|
||||
|
||||
const emptyMessage: ChatMessage = {
|
||||
id: '4',
|
||||
text: ' ',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
};
|
||||
|
||||
wrapper = mount(MessageActions, {
|
||||
props: {
|
||||
message: emptyMessage,
|
||||
},
|
||||
});
|
||||
|
||||
const repostIcon = wrapper.find('[icon="redo-2"]');
|
||||
await repostIcon.trigger('click');
|
||||
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not copy empty messages to input', async () => {
|
||||
const emptyMessage: ChatMessage = {
|
||||
id: '5',
|
||||
text: ' ',
|
||||
sender: 'user',
|
||||
type: 'text',
|
||||
};
|
||||
|
||||
wrapper = mount(MessageActions, {
|
||||
props: {
|
||||
message: emptyMessage,
|
||||
},
|
||||
});
|
||||
|
||||
const copyIcon = wrapper.find('[icon="files"]');
|
||||
await copyIcon.trigger('click');
|
||||
|
||||
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import MessageWithButtons from '../components/MessageWithButtons.vue';
|
||||
|
||||
vi.mock('../components/MarkdownRenderer.vue', () => ({
|
||||
default: {
|
||||
name: 'MarkdownRenderer',
|
||||
template: '<div>{{ text }}</div>',
|
||||
props: ['text'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/chat/composables', () => ({
|
||||
useOptions: () => ({
|
||||
options: {
|
||||
webhookUrl: 'https://webhook.example.com/webhook/123/chat',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const editorOrigin = 'http://localhost:5678';
|
||||
const webhookOrigin = 'https://webhook.example.com';
|
||||
|
||||
const relativeUrlButtons = [
|
||||
{ text: 'Confirm', link: '/api/confirm', type: 'primary' as const },
|
||||
{ text: 'Cancel', link: '/api/cancel', type: 'secondary' as const },
|
||||
];
|
||||
|
||||
describe('MessageWithButtons', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: new URL(`${editorOrigin}/chat`),
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not render buttons whose links do not match the configured webhook origin', () => {
|
||||
const wrapper = mount(MessageWithButtons, {
|
||||
props: { text: 'Please confirm', buttons: relativeUrlButtons },
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('button')).toHaveLength(0);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders and fetches when the link is on the configured webhook origin', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
const webhookButtons = [
|
||||
{
|
||||
text: 'Approve',
|
||||
link: `${webhookOrigin}/webhook/123/approve`,
|
||||
type: 'primary' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = mount(MessageWithButtons, {
|
||||
props: { text: 'Please approve', buttons: webhookButtons },
|
||||
});
|
||||
|
||||
expect(wrapper.find('button').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('button').trigger('click');
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(`${webhookOrigin}/webhook/123/approve`));
|
||||
});
|
||||
|
||||
it('does not render a button when the link points to an unrecognized origin', () => {
|
||||
const externalButtons = [
|
||||
{ text: 'Go', link: 'https://broken-link.com/approve', type: 'primary' as const },
|
||||
];
|
||||
|
||||
const wrapper = mount(MessageWithButtons, {
|
||||
props: { text: 'Click me', buttons: externalButtons },
|
||||
});
|
||||
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render a button for an absolute URL on a different host with the same port', () => {
|
||||
const externalButtons = [
|
||||
{ text: 'Go', link: 'http://other-host:5678/api/confirm', type: 'primary' as const },
|
||||
];
|
||||
|
||||
const wrapper = mount(MessageWithButtons, {
|
||||
props: { text: 'Click me', buttons: externalButtons },
|
||||
});
|
||||
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders only valid-origin buttons when the list contains mixed URLs', () => {
|
||||
const mixedButtons = [
|
||||
{ text: 'Editor', link: '/api/confirm', type: 'primary' as const },
|
||||
{
|
||||
text: 'Webhook',
|
||||
link: `${webhookOrigin}/webhook/123/approve`,
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{ text: 'Other', link: 'http://broken-url/approve', type: 'secondary' as const },
|
||||
];
|
||||
|
||||
const wrapper = mount(MessageWithButtons, {
|
||||
props: { text: 'Choose', buttons: mixedButtons },
|
||||
});
|
||||
|
||||
const rendered = wrapper.findAll('button');
|
||||
expect(rendered).toHaveLength(1);
|
||||
expect(rendered[0].text()).toBe('Webhook');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { postWithFiles } from '@n8n/chat/api/generic';
|
||||
|
||||
describe('postWithFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should properly serialize object metadata to JSON string in FormData', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => await Promise.resolve({ success: true }),
|
||||
text: async () => await Promise.resolve('success'),
|
||||
clone: () => mockResponse,
|
||||
} as Response;
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const testFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
||||
const metadata = {
|
||||
userId: 'user-123',
|
||||
token: 'abc-def-ghi',
|
||||
nested: {
|
||||
prop: 'value',
|
||||
num: 42,
|
||||
},
|
||||
};
|
||||
|
||||
await postWithFiles(
|
||||
'https://example.com/webhook',
|
||||
{
|
||||
action: 'sendMessage',
|
||||
sessionId: 'test-session',
|
||||
chatInput: 'test message',
|
||||
metadata,
|
||||
},
|
||||
[testFile],
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/webhook', {
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
// Get the FormData from the call
|
||||
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
|
||||
// Verify that metadata was properly serialized as JSON, not "[object Object]"
|
||||
const metadataValue = formData.get('metadata');
|
||||
expect(metadataValue).toBe(JSON.stringify(metadata));
|
||||
|
||||
// Verify other fields are still strings
|
||||
expect(formData.get('action')).toBe('sendMessage');
|
||||
expect(formData.get('sessionId')).toBe('test-session');
|
||||
expect(formData.get('chatInput')).toBe('test message');
|
||||
|
||||
// Verify file was included
|
||||
expect(formData.get('files')).toBe(testFile);
|
||||
});
|
||||
|
||||
it('should handle primitive values correctly', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => await Promise.resolve({ success: true }),
|
||||
text: async () => await Promise.resolve('success'),
|
||||
clone: () => mockResponse,
|
||||
} as Response;
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await postWithFiles('https://example.com/webhook', {
|
||||
stringValue: 'test',
|
||||
});
|
||||
|
||||
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
|
||||
|
||||
expect(formData.get('stringValue')).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle arrays as JSON strings', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => await Promise.resolve({ success: true }),
|
||||
text: async () => await Promise.resolve('success'),
|
||||
clone: () => mockResponse,
|
||||
} as Response;
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const arrayValue = ['item1', 'item2', { nested: 'object' }];
|
||||
|
||||
await postWithFiles('https://example.com/webhook', {
|
||||
arrayValue,
|
||||
});
|
||||
|
||||
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
|
||||
expect(formData.get('arrayValue')).toBe(JSON.stringify(arrayValue));
|
||||
});
|
||||
|
||||
it('should handle empty objects correctly', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => await Promise.resolve({ success: true }),
|
||||
text: async () => await Promise.resolve('success'),
|
||||
clone: () => mockResponse,
|
||||
} as Response;
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await postWithFiles('https://example.com/webhook', {
|
||||
emptyObject: {},
|
||||
});
|
||||
|
||||
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
|
||||
expect(formData.get('emptyObject')).toBe('{}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { sendMessageStreaming } from '@n8n/chat/api';
|
||||
import type { ChatOptions } from '@n8n/chat/types';
|
||||
|
||||
describe('sendMessageStreaming', () => {
|
||||
const mockOptions: ChatOptions = {
|
||||
webhookUrl: 'https://test.example.com/webhook',
|
||||
chatSessionKey: 'sessionId',
|
||||
chatInputKey: 'chatInput',
|
||||
i18n: {
|
||||
en: {
|
||||
title: 'Test',
|
||||
subtitle: 'Test',
|
||||
footer: 'Test',
|
||||
getStarted: 'Test',
|
||||
inputPlaceholder: 'Test',
|
||||
closeButtonTooltip: 'Test',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call the webhook URL with correct parameters', async () => {
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'Hello ',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'World!',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const onChunk = vi.fn();
|
||||
const onBeginMessage = vi.fn();
|
||||
const onEndMessage = vi.fn();
|
||||
|
||||
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
|
||||
onChunk,
|
||||
onBeginMessage,
|
||||
onEndMessage,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'sendMessage',
|
||||
sessionId: 'test-session-id',
|
||||
chatInput: 'Test message',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(onBeginMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 0);
|
||||
expect(onChunk).toHaveBeenCalledTimes(2);
|
||||
expect(onChunk).toHaveBeenCalledWith('Hello ', 'node-1', 0);
|
||||
expect(onChunk).toHaveBeenCalledWith('World!', 'node-1', 0);
|
||||
expect(onEndMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onEndMessage).toHaveBeenCalledWith('node-1', 0);
|
||||
});
|
||||
|
||||
it('should handle multiple runs and items correctly', async () => {
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'Run 0 Item 0 ',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 1,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'Run 1 Item 0 ',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 1,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 1,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const onChunk = vi.fn();
|
||||
const onBeginMessage = vi.fn();
|
||||
const onEndMessage = vi.fn();
|
||||
|
||||
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
|
||||
onChunk,
|
||||
onBeginMessage,
|
||||
onEndMessage,
|
||||
});
|
||||
|
||||
expect(onBeginMessage).toHaveBeenCalledTimes(2);
|
||||
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 0);
|
||||
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 1);
|
||||
expect(onChunk).toHaveBeenCalledTimes(2);
|
||||
expect(onChunk).toHaveBeenCalledWith('Run 0 Item 0 ', 'node-1', 0);
|
||||
expect(onChunk).toHaveBeenCalledWith('Run 1 Item 0 ', 'node-1', 1);
|
||||
expect(onEndMessage).toHaveBeenCalledTimes(2);
|
||||
expect(onEndMessage).toHaveBeenCalledWith('node-1', 0);
|
||||
expect(onEndMessage).toHaveBeenCalledWith('node-1', 1);
|
||||
});
|
||||
|
||||
it('should support file uploads with streaming', async () => {
|
||||
const testFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'File processed: ',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'test.txt',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
runIndex: 0,
|
||||
itemIndex: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const onChunk = vi.fn();
|
||||
const onBeginMessage = vi.fn();
|
||||
const onEndMessage = vi.fn();
|
||||
|
||||
await sendMessageStreaming('Test message', [testFile], 'test-session-id', mockOptions, {
|
||||
onChunk,
|
||||
onBeginMessage,
|
||||
onEndMessage,
|
||||
});
|
||||
|
||||
// Verify FormData was used for file upload
|
||||
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
});
|
||||
|
||||
expect(onBeginMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 0);
|
||||
expect(onChunk).toHaveBeenCalledTimes(2);
|
||||
expect(onChunk).toHaveBeenCalledWith('File processed: ', 'node-1', 0);
|
||||
expect(onChunk).toHaveBeenCalledWith('test.txt', 'node-1', 0);
|
||||
expect(onEndMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onEndMessage).toHaveBeenCalledWith('node-1', 0);
|
||||
});
|
||||
|
||||
it('should strip Content-Type header when uploading files even if set in webhookConfig', async () => {
|
||||
const optionsWithContentType: ChatOptions = {
|
||||
...mockOptions,
|
||||
webhookConfig: {
|
||||
headers: { 'Content-Type': 'application/json', 'X-Custom': 'value' },
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"type":"end"}\n'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await sendMessageStreaming(
|
||||
'test',
|
||||
[new File([''], 'test.txt')],
|
||||
'session',
|
||||
optionsWithContentType,
|
||||
{
|
||||
onChunk: vi.fn(),
|
||||
onEndMessage: vi.fn(),
|
||||
onBeginMessage: vi.fn(),
|
||||
},
|
||||
);
|
||||
|
||||
// Content-Type must be excluded for FormData (browser sets it with boundary)
|
||||
// Other custom headers should still be included
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'text/plain', 'X-Custom': 'value' },
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle HTTP errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: new Headers(),
|
||||
text: async () => 'Internal Server Error',
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
|
||||
onChunk: vi.fn(),
|
||||
onEndMessage: vi.fn(),
|
||||
onBeginMessage: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow('Error while sending message. Error: Internal Server Error');
|
||||
});
|
||||
|
||||
it('should handle missing response body', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: null,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
|
||||
onChunk: vi.fn(),
|
||||
onEndMessage: vi.fn(),
|
||||
onBeginMessage: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow('Response body is not readable');
|
||||
});
|
||||
|
||||
it('should include custom headers from webhook config', async () => {
|
||||
const optionsWithHeaders: ChatOptions = {
|
||||
...mockOptions,
|
||||
webhookConfig: {
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
'X-Custom-Header': 'value',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
|
||||
},
|
||||
{ type: 'end', metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() } },
|
||||
];
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await sendMessageStreaming('Test message', [], 'test-session-id', optionsWithHeaders, {
|
||||
onChunk: vi.fn(),
|
||||
onEndMessage: vi.fn(),
|
||||
onBeginMessage: vi.fn(),
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/plain',
|
||||
Authorization: 'Bearer token',
|
||||
'X-Custom-Header': 'value',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'sendMessage',
|
||||
sessionId: 'test-session-id',
|
||||
chatInput: 'Test message',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should include metadata when provided', async () => {
|
||||
const optionsWithMetadata: ChatOptions = {
|
||||
...mockOptions,
|
||||
metadata: {
|
||||
userId: 'user-123',
|
||||
source: 'chat-widget',
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
|
||||
},
|
||||
{ type: 'end', metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() } },
|
||||
];
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await sendMessageStreaming('Test message', [], 'test-session-id', optionsWithMetadata, {
|
||||
onChunk: vi.fn(),
|
||||
onEndMessage: vi.fn(),
|
||||
onBeginMessage: vi.fn(),
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'sendMessage',
|
||||
sessionId: 'test-session-id',
|
||||
chatInput: 'Test message',
|
||||
metadata: {
|
||||
userId: 'user-123',
|
||||
source: 'chat-widget',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('async handlers', () => {
|
||||
it('should support async onEndMessage handler', async () => {
|
||||
const onEndMessage = vi.fn().mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
|
||||
},
|
||||
];
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
|
||||
onChunk: vi.fn(),
|
||||
onEndMessage,
|
||||
onBeginMessage: vi.fn(),
|
||||
});
|
||||
|
||||
expect(onEndMessage).toHaveBeenCalledWith('node-1', undefined);
|
||||
});
|
||||
|
||||
it('should await async onEndMessage on error chunks', async () => {
|
||||
const onEndMessage = vi.fn().mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
content: 'Something went wrong',
|
||||
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
|
||||
},
|
||||
];
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const onChunk = vi.fn();
|
||||
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
|
||||
onChunk,
|
||||
onEndMessage,
|
||||
onBeginMessage: vi.fn(),
|
||||
});
|
||||
|
||||
expect(onChunk).toHaveBeenCalledWith('Error: Something went wrong', 'node-1', undefined);
|
||||
expect(onEndMessage).toHaveBeenCalledWith('node-1', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
|
||||
import {
|
||||
createFetchResponse,
|
||||
createGetLatestMessagesResponse,
|
||||
createSendMessageResponse,
|
||||
createMockStreamingFetchResponse,
|
||||
getChatInputSendButton,
|
||||
getChatInputTextarea,
|
||||
getChatMessage,
|
||||
getChatMessageByText,
|
||||
getChatMessages,
|
||||
getChatMessageTyping,
|
||||
getChatWindowToggle,
|
||||
getChatWindowWrapper,
|
||||
getChatWrapper,
|
||||
getGetStartedButton,
|
||||
getMountingTarget,
|
||||
} from '@n8n/chat/__tests__/utils';
|
||||
import { createChat } from '@n8n/chat/index';
|
||||
|
||||
describe('createChat()', () => {
|
||||
let app: ReturnType<typeof createChat>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
describe('mode', () => {
|
||||
it('should create fullscreen chat app with default options', () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
});
|
||||
|
||||
expect(getMountingTarget()).toBeVisible();
|
||||
expect(getChatWrapper()).toBeVisible();
|
||||
expect(getChatWindowWrapper()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create window chat app with default options', () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat({
|
||||
mode: 'window',
|
||||
});
|
||||
|
||||
expect(getMountingTarget()).toBeDefined();
|
||||
expect(getChatWindowWrapper()).toBeVisible();
|
||||
expect(getChatWrapper()).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should open window chat app using toggle button', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat();
|
||||
|
||||
expect(getMountingTarget()).toBeVisible();
|
||||
expect(getChatWindowWrapper()).toBeVisible();
|
||||
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
|
||||
expect(getChatWrapper()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPreviousMessages', () => {
|
||||
it('should load previous messages on mount', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch');
|
||||
fetchSpy.mockImplementation(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
showWelcomeScreen: true,
|
||||
});
|
||||
|
||||
const getStartedButton = getGetStartedButton();
|
||||
await fireEvent.click(getStartedButton as HTMLElement);
|
||||
|
||||
expect(fetchSpy.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: expect.stringContaining('"action":"loadPreviousSession"') as unknown,
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialMessages', () => {
|
||||
it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)(
|
||||
'should show initial default messages in %s mode',
|
||||
async (mode) => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
|
||||
|
||||
const initialMessages = ['Hello tester!', 'How are you?'];
|
||||
app = createChat({
|
||||
mode,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
if (mode === 'window') {
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
}
|
||||
|
||||
expect(getChatMessages().length).toBe(initialMessages.length);
|
||||
expect(getChatMessageByText(initialMessages[0])).toBeInTheDocument();
|
||||
expect(getChatMessageByText(initialMessages[1])).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it.each(['window', 'fullscreen'] as Array<'fullscreen' | 'window'>)(
|
||||
'should send a message and render a text message in %s mode',
|
||||
async (mode) => {
|
||||
const input = 'Hello User World!';
|
||||
const output = 'Hello Bot World!';
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
|
||||
|
||||
app = createChat({
|
||||
mode,
|
||||
});
|
||||
|
||||
if (mode === 'window') {
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
}
|
||||
|
||||
expect(getChatMessageTyping()).not.toBeInTheDocument();
|
||||
expect(getChatMessages().length).toBe(2);
|
||||
|
||||
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(fetchSpy.mock.calls[1][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: expect.stringMatching(/"action":"sendMessage"/) as unknown,
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy.mock.calls[1][1]?.body).toContain(`"${input}"`);
|
||||
|
||||
expect(getChatMessages().length).toBe(3);
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
expect(getChatMessageTyping()).toBeVisible();
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
expect(getChatMessageByText(output)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)(
|
||||
'should send a message and render a code markdown message in %s mode',
|
||||
async (mode) => {
|
||||
const input = 'Teach me javascript!';
|
||||
const output = '# Code\n```js\nconsole.log("Hello World!");\n```';
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
|
||||
|
||||
app = createChat({
|
||||
mode,
|
||||
});
|
||||
|
||||
if (mode === 'window') {
|
||||
const trigger = getChatWindowToggle();
|
||||
await fireEvent.click(trigger as HTMLElement);
|
||||
}
|
||||
|
||||
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
expect(getChatMessages().length).toBe(3);
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
|
||||
const lastMessage = getChatMessage(-1);
|
||||
expect(lastMessage).toBeInTheDocument();
|
||||
|
||||
expect(lastMessage.querySelector('h1')).toHaveTextContent('Code');
|
||||
expect(lastMessage.querySelector('code')).toHaveTextContent('console.log("Hello World!");');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('streaming', () => {
|
||||
it('should handle streaming responses when enableStreaming is true', async () => {
|
||||
const input = 'Tell me a story!';
|
||||
const chunks = [
|
||||
{
|
||||
type: 'begin',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
itemIndex: 0,
|
||||
runIndex: 0,
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'Once upon ',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
itemIndex: 0,
|
||||
runIndex: 0,
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'a time, ',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
itemIndex: 0,
|
||||
runIndex: 0,
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
content: 'there was a test.',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
itemIndex: 0,
|
||||
runIndex: 0,
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
metadata: {
|
||||
nodeId: 'node-1',
|
||||
itemIndex: 0,
|
||||
runIndex: 0,
|
||||
nodeName: 'Test Node',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(createMockStreamingFetchResponse(chunks));
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
enableStreaming: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
expect(getChatMessages().length).toBe(3);
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
|
||||
const expectedOutput = 'Once upon a time, there was a test.';
|
||||
await waitFor(() => expect(getChatMessageByText(expectedOutput)).toBeInTheDocument());
|
||||
|
||||
expect(fetchSpy.mock.calls[1][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
body: expect.stringMatching(/"action":"sendMessage"/) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to regular API when enableStreaming is false', async () => {
|
||||
const input = 'Hello!';
|
||||
const output = 'Hello Bot World!';
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
enableStreaming: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
expect(getChatMessageByText(output)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle streaming errors gracefully', async () => {
|
||||
const input = 'This should fail!';
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch');
|
||||
fetchSpy
|
||||
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
app = createChat({
|
||||
mode: 'fullscreen',
|
||||
enableStreaming: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
|
||||
|
||||
const textarea = getChatInputTextarea();
|
||||
const sendButton = getChatInputSendButton();
|
||||
await fireEvent.update(textarea as HTMLElement, input);
|
||||
await fireEvent.click(sendButton as HTMLElement);
|
||||
|
||||
expect(getChatMessageByText(input)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
|
||||
expect(getChatMessageByText('Error: Failed to receive response')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import * as api from '@n8n/chat/api';
|
||||
import type { StreamingEventHandlers } from '@n8n/chat/api/message';
|
||||
import { localStorageSessionIdKey } from '@n8n/chat/constants';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import { ChatPlugin } from '@n8n/chat/plugins/chat';
|
||||
import type { Chat, ChatOptions, LoadPreviousSessionResponse } from '@n8n/chat/types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@n8n/chat/api');
|
||||
vi.mock('@n8n/chat/event-buses', () => ({
|
||||
chatEventBus: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to set up chat store with proper typing
|
||||
function setupChatStore(options: ChatOptions): Chat {
|
||||
const app = createApp({
|
||||
template: '<div></div>',
|
||||
});
|
||||
app.use(ChatPlugin, options);
|
||||
return app.config.globalProperties.$chat as Chat;
|
||||
}
|
||||
|
||||
describe('ChatPlugin', () => {
|
||||
let mockOptions: ChatOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default options
|
||||
mockOptions = {
|
||||
webhookUrl: 'http://localhost:5678/webhook',
|
||||
chatInputKey: 'message',
|
||||
chatSessionKey: 'sessionId',
|
||||
enableStreaming: false,
|
||||
initialMessages: [], // Explicitly set to empty to override defaults
|
||||
i18n: {
|
||||
en: {
|
||||
title: 'Test Chat',
|
||||
subtitle: 'Test subtitle',
|
||||
footer: '',
|
||||
getStarted: 'Start',
|
||||
inputPlaceholder: 'Type a message...',
|
||||
closeButtonTooltip: 'Close',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Setup localStorage mock
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
let chatStore: Chat;
|
||||
|
||||
beforeEach(() => {
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
});
|
||||
|
||||
it('should send a message without streaming', async () => {
|
||||
const mockResponse = { output: 'Hello from bot!' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('Hello bot!');
|
||||
|
||||
expect(api.sendMessage).toHaveBeenCalledWith('Hello bot!', [], null, mockOptions);
|
||||
|
||||
expect(chatStore.messages.value).toHaveLength(2);
|
||||
expect(chatStore.messages.value[0]).toMatchObject({
|
||||
text: 'Hello bot!',
|
||||
sender: 'user',
|
||||
});
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: 'Hello from bot!',
|
||||
sender: 'bot',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty response gracefully', async () => {
|
||||
const mockResponse = {};
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('Hello bot!');
|
||||
|
||||
expect(chatStore.messages.value).toHaveLength(2);
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: '',
|
||||
sender: 'bot',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle response with only text property', async () => {
|
||||
const mockResponse = { text: 'Response text' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('Hello bot!');
|
||||
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: 'Response text',
|
||||
sender: 'bot',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors during message sending', async () => {
|
||||
vi.mocked(api.sendMessage).mockRejectedValueOnce(new Error('Network error'));
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await chatStore.sendMessage('Hello bot!');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Chat API error:', expect.any(Error));
|
||||
|
||||
// Error messages should be displayed to the user
|
||||
expect(chatStore.messages.value).toHaveLength(2);
|
||||
expect(chatStore.messages.value[0]).toMatchObject({
|
||||
text: 'Hello bot!',
|
||||
sender: 'user',
|
||||
});
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: 'Error: Failed to receive response',
|
||||
sender: 'bot',
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should send files with message', async () => {
|
||||
const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||
const mockResponse = { output: 'File received!' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('Here is a file', [mockFile]);
|
||||
|
||||
expect(api.sendMessage).toHaveBeenCalledWith('Here is a file', [mockFile], null, mockOptions);
|
||||
|
||||
expect(chatStore.messages.value[0]).toMatchObject({
|
||||
text: 'Here is a file',
|
||||
sender: 'user',
|
||||
files: [mockFile],
|
||||
});
|
||||
});
|
||||
|
||||
it('should set waitingForResponse correctly', async () => {
|
||||
const mockResponse = { output: 'Response' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
expect(chatStore.waitingForResponse.value).toBe(false);
|
||||
|
||||
const sendPromise = chatStore.sendMessage('Test');
|
||||
expect(chatStore.waitingForResponse.value).toBe(true);
|
||||
|
||||
await sendPromise;
|
||||
expect(chatStore.waitingForResponse.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should emit scrollToBottom events', async () => {
|
||||
const mockResponse = { output: 'Response' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('Test');
|
||||
|
||||
expect(chatEventBus.emit).toHaveBeenCalledWith('scrollToBottom');
|
||||
expect(chatEventBus.emit).toHaveBeenCalledTimes(2); // Once after user message, once after bot response
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming', () => {
|
||||
let chatStore: Chat;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions.enableStreaming = true;
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
});
|
||||
|
||||
it('should handle streaming messages', async () => {
|
||||
const mockStreamingResponse = { hasReceivedChunks: true };
|
||||
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
|
||||
|
||||
await chatStore.sendMessage('Stream this!');
|
||||
|
||||
expect(api.sendMessageStreaming).toHaveBeenCalledWith(
|
||||
'Stream this!',
|
||||
[],
|
||||
null,
|
||||
mockOptions,
|
||||
expect.objectContaining({
|
||||
onChunk: expect.any(Function) as StreamingEventHandlers['onChunk'],
|
||||
onBeginMessage: expect.any(Function) as StreamingEventHandlers['onBeginMessage'],
|
||||
onEndMessage: expect.any(Function) as StreamingEventHandlers['onEndMessage'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty streaming response', async () => {
|
||||
const mockStreamingResponse = { hasReceivedChunks: false };
|
||||
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
|
||||
|
||||
await chatStore.sendMessage('Stream this!');
|
||||
|
||||
expect(chatStore.messages.value).toHaveLength(2);
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: '[No response received. This could happen if streaming is enabled in the trigger but disabled in agent node(s)]',
|
||||
sender: 'bot',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle streaming errors', async () => {
|
||||
vi.mocked(api.sendMessageStreaming).mockRejectedValueOnce(new Error('Stream error'));
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await chatStore.sendMessage('Stream this!');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Chat API error:', expect.any(Error));
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: 'Error: Failed to receive response',
|
||||
sender: 'bot',
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle streaming with files', async () => {
|
||||
const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||||
const mockStreamingResponse = { hasReceivedChunks: true };
|
||||
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
|
||||
|
||||
await chatStore.sendMessage('Stream with file', [mockFile]);
|
||||
|
||||
expect(api.sendMessageStreaming).toHaveBeenCalledWith(
|
||||
'Stream with file',
|
||||
[mockFile],
|
||||
null,
|
||||
mockOptions,
|
||||
expect.objectContaining({
|
||||
onChunk: expect.any(Function) as StreamingEventHandlers['onChunk'],
|
||||
onBeginMessage: expect.any(Function) as StreamingEventHandlers['onBeginMessage'],
|
||||
onEndMessage: expect.any(Function) as StreamingEventHandlers['onEndMessage'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session management', () => {
|
||||
let chatStore: Chat;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions.loadPreviousSession = true;
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
});
|
||||
|
||||
it('should load previous session', async () => {
|
||||
const mockSessionId = 'existing-session';
|
||||
const mockMessages: LoadPreviousSessionResponse = {
|
||||
data: [
|
||||
{
|
||||
id: ['HumanMessage-1'], // The implementation expects string but types say array
|
||||
kwargs: { content: 'Previous user message', additional_kwargs: {} },
|
||||
lc: 1,
|
||||
type: 'HumanMessage',
|
||||
},
|
||||
{
|
||||
id: ['AIMessage-1'],
|
||||
kwargs: { content: 'Previous bot message', additional_kwargs: {} },
|
||||
lc: 1,
|
||||
type: 'AIMessage',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(mockSessionId);
|
||||
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce(mockMessages);
|
||||
|
||||
const sessionId = await chatStore.loadPreviousSession?.();
|
||||
|
||||
expect(sessionId).toBe(mockSessionId);
|
||||
expect(api.loadPreviousSession).toHaveBeenCalledWith(mockSessionId, mockOptions);
|
||||
expect(chatStore.messages.value).toHaveLength(2);
|
||||
expect(chatStore.messages.value[0]).toMatchObject({
|
||||
text: 'Previous user message',
|
||||
sender: 'bot', // Both will be 'bot' because id is an array, not a string
|
||||
});
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: 'Previous bot message',
|
||||
sender: 'bot',
|
||||
});
|
||||
expect(chatStore.currentSessionId.value).toBe(mockSessionId);
|
||||
});
|
||||
|
||||
it('should create new session if no previous session exists', async () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(null);
|
||||
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
const sessionId = await chatStore.loadPreviousSession?.();
|
||||
|
||||
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
expect(chatStore.messages.value).toHaveLength(0);
|
||||
expect(chatStore.currentSessionId.value).toBe(sessionId);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageSessionIdKey, sessionId);
|
||||
});
|
||||
|
||||
it('should preserve manually set sessionId when no messages exist', async () => {
|
||||
const manualSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
manualSessionId,
|
||||
);
|
||||
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
const sessionId = await chatStore.loadPreviousSession?.();
|
||||
|
||||
expect(sessionId).toBe(manualSessionId);
|
||||
expect(chatStore.currentSessionId.value).toBe(manualSessionId);
|
||||
expect(chatStore.messages.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should preserve manually set sessionId when messages exist', async () => {
|
||||
const manualSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
manualSessionId,
|
||||
);
|
||||
const mockMessages: LoadPreviousSessionResponse = {
|
||||
data: [
|
||||
{
|
||||
id: ['user', 'uuid-1'],
|
||||
kwargs: { content: 'Hello', additional_kwargs: {} },
|
||||
lc: 1,
|
||||
type: 'HumanMessage',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce(mockMessages);
|
||||
|
||||
const sessionId = await chatStore.loadPreviousSession?.();
|
||||
|
||||
expect(sessionId).toBe(manualSessionId);
|
||||
expect(chatStore.currentSessionId.value).toBe(manualSessionId);
|
||||
expect(chatStore.messages.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should skip loading if loadPreviousSession is false', async () => {
|
||||
mockOptions.loadPreviousSession = false;
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
|
||||
const result = await chatStore.loadPreviousSession?.();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(api.loadPreviousSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start a new session when localStorage is empty', async () => {
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(null);
|
||||
|
||||
await chatStore.startNewSession?.();
|
||||
|
||||
expect(chatStore.currentSessionId.value).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith(
|
||||
localStorageSessionIdKey,
|
||||
chatStore.currentSessionId.value,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing sessionId when starting new session with loadPreviousSession enabled', async () => {
|
||||
const existingSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
|
||||
mockOptions.loadPreviousSession = true;
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
existingSessionId,
|
||||
);
|
||||
|
||||
await chatStore.startNewSession?.();
|
||||
|
||||
expect(chatStore.currentSessionId.value).toBe(existingSessionId);
|
||||
// localStorage.setItem should not be called since sessionId already exists
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(window.localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate new sessionId when loadPreviousSession is disabled', async () => {
|
||||
const existingSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
|
||||
mockOptions.loadPreviousSession = false;
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
existingSessionId,
|
||||
);
|
||||
|
||||
await chatStore.startNewSession?.();
|
||||
|
||||
// Should generate new UUID, not preserve existing one
|
||||
expect(chatStore.currentSessionId.value).not.toBe(existingSessionId);
|
||||
expect(chatStore.currentSessionId.value).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
// localStorage.setItem should be called with new sessionId
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith(
|
||||
localStorageSessionIdKey,
|
||||
chatStore.currentSessionId.value,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial messages', () => {
|
||||
it('should compute initial messages from options', () => {
|
||||
mockOptions.initialMessages = ['Welcome!', 'How can I help you?'];
|
||||
const chatStore = setupChatStore(mockOptions);
|
||||
|
||||
expect(chatStore.initialMessages.value).toHaveLength(2);
|
||||
expect(chatStore.initialMessages.value[0]).toMatchObject({
|
||||
text: 'Welcome!',
|
||||
sender: 'bot',
|
||||
});
|
||||
expect(chatStore.initialMessages.value[1]).toMatchObject({
|
||||
text: 'How can I help you?',
|
||||
sender: 'bot',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined initial messages', () => {
|
||||
const chatStore = setupChatStore(mockOptions);
|
||||
|
||||
expect(chatStore.initialMessages.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeMessageSent and afterMessageSent hooks', () => {
|
||||
it('should call beforeMessageSent before sending (non-streaming)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const beforeMessageSent = vi.fn(() => {
|
||||
callOrder.push('before');
|
||||
});
|
||||
vi.mocked(api.sendMessage).mockImplementation(async () => {
|
||||
callOrder.push('send');
|
||||
return { output: 'Response' };
|
||||
});
|
||||
|
||||
const chatStore = setupChatStore({ ...mockOptions, beforeMessageSent });
|
||||
await chatStore.sendMessage('Test message');
|
||||
|
||||
expect(beforeMessageSent).toHaveBeenCalledWith('Test message');
|
||||
expect(callOrder).toEqual(['before', 'send']);
|
||||
});
|
||||
|
||||
it('should call afterMessageSent after sending (non-streaming)', async () => {
|
||||
const mockResponse = { output: 'Response' };
|
||||
const afterMessageSent = vi.fn();
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const chatStore = setupChatStore({
|
||||
...mockOptions,
|
||||
webhookConfig: { method: 'POST' },
|
||||
afterMessageSent,
|
||||
});
|
||||
await chatStore.sendMessage('Test message');
|
||||
|
||||
expect(afterMessageSent).toHaveBeenCalledWith('Test message', mockResponse);
|
||||
});
|
||||
|
||||
it('should call beforeMessageSent before sending (streaming)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const beforeMessageSent = vi.fn(() => {
|
||||
callOrder.push('before');
|
||||
});
|
||||
vi.mocked(api.sendMessageStreaming).mockImplementation(async () => {
|
||||
callOrder.push('stream');
|
||||
return { hasReceivedChunks: true };
|
||||
});
|
||||
|
||||
const chatStore = setupChatStore({
|
||||
...mockOptions,
|
||||
enableStreaming: true,
|
||||
beforeMessageSent,
|
||||
});
|
||||
await chatStore.sendMessage('Test message');
|
||||
|
||||
expect(beforeMessageSent).toHaveBeenCalledWith('Test message');
|
||||
expect(callOrder).toEqual(['before', 'stream']);
|
||||
});
|
||||
|
||||
it('should call afterMessageSent after streaming completes', async () => {
|
||||
const afterMessageSent = vi.fn();
|
||||
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce({ hasReceivedChunks: true });
|
||||
|
||||
const chatStore = setupChatStore({
|
||||
...mockOptions,
|
||||
enableStreaming: true,
|
||||
afterMessageSent,
|
||||
});
|
||||
await chatStore.sendMessage('Test message');
|
||||
|
||||
expect(afterMessageSent).toHaveBeenCalledWith(
|
||||
'Test message',
|
||||
expect.objectContaining({
|
||||
hasReceivedChunks: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call hooks in correct order (non-streaming)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const beforeMessageSent = vi.fn(() => {
|
||||
callOrder.push('before');
|
||||
});
|
||||
const afterMessageSent = vi.fn(() => {
|
||||
callOrder.push('after');
|
||||
});
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce({ output: 'Response' });
|
||||
|
||||
const chatStore = setupChatStore({
|
||||
...mockOptions,
|
||||
webhookConfig: { method: 'POST' },
|
||||
beforeMessageSent,
|
||||
afterMessageSent,
|
||||
});
|
||||
await chatStore.sendMessage('Test message');
|
||||
|
||||
expect(callOrder).toEqual(['before', 'after']);
|
||||
});
|
||||
|
||||
it('should call beforeMessageSent with file uploads', async () => {
|
||||
const beforeMessageSent = vi.fn();
|
||||
const testFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce({ output: 'File received' });
|
||||
|
||||
const chatStore = setupChatStore({ ...mockOptions, beforeMessageSent });
|
||||
await chatStore.sendMessage('Test message', [testFile]);
|
||||
|
||||
expect(beforeMessageSent).toHaveBeenCalledWith('Test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
let chatStore: Chat;
|
||||
|
||||
beforeEach(() => {
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
});
|
||||
|
||||
it('should handle sending message with null session ID', async () => {
|
||||
const mockResponse = { output: 'Response' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
chatStore.currentSessionId.value = null;
|
||||
await chatStore.sendMessage('Test');
|
||||
|
||||
expect(api.sendMessage).toHaveBeenCalledWith('Test', [], null, mockOptions);
|
||||
});
|
||||
|
||||
it('should handle empty text message', async () => {
|
||||
const mockResponse = { output: 'Response' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('');
|
||||
|
||||
expect(chatStore.messages.value[0]).toMatchObject({
|
||||
text: '',
|
||||
sender: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle streaming with existing bot messages', async () => {
|
||||
mockOptions.enableStreaming = true;
|
||||
chatStore = setupChatStore(mockOptions);
|
||||
|
||||
// Add an existing bot message
|
||||
chatStore.messages.value.push({
|
||||
id: 'existing',
|
||||
text: 'Existing message',
|
||||
sender: 'bot',
|
||||
});
|
||||
|
||||
const mockStreamingResponse = { hasReceivedChunks: false };
|
||||
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
|
||||
|
||||
await chatStore.sendMessage('Test');
|
||||
|
||||
// Should still add error message even with existing bot messages
|
||||
const lastMessage = chatStore.messages.value[chatStore.messages.value.length - 1];
|
||||
assert(lastMessage.type === 'text');
|
||||
expect(lastMessage.text).toBe(
|
||||
'[No response received. This could happen if streaming is enabled in the trigger but disabled in agent node(s)]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return response when executionStarted is true', async () => {
|
||||
const mockResponse = {
|
||||
executionStarted: true,
|
||||
executionId: '12345',
|
||||
};
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await chatStore.sendMessage('Execute workflow');
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
// Should only have the user message, no bot response
|
||||
expect(chatStore.messages.value).toHaveLength(1);
|
||||
expect(chatStore.messages.value[0]).toMatchObject({
|
||||
text: 'Execute workflow',
|
||||
sender: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle message field in response', async () => {
|
||||
const mockResponse = { message: 'Response from message field' };
|
||||
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await chatStore.sendMessage('Test message field');
|
||||
|
||||
expect(chatStore.messages.value[1]).toMatchObject({
|
||||
text: 'Response from message field',
|
||||
sender: 'bot',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import * as api from '@n8n/chat/api';
|
||||
|
||||
import { ChatPlugin } from '../../plugins/chat';
|
||||
|
||||
vi.mock('@n8n/chat/api');
|
||||
|
||||
describe('ChatPlugin', () => {
|
||||
it('should return sendMessageResponse when executionStarted is true', async () => {
|
||||
const app = createApp({});
|
||||
const options = {
|
||||
webhookUrl: 'test',
|
||||
i18n: {
|
||||
en: {
|
||||
message: 'message',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
footer: 'footer',
|
||||
getStarted: 'getStarted',
|
||||
inputPlaceholder: 'inputPlaceholder',
|
||||
closeButtonTooltip: 'closeButtonTooltip',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(api.sendMessage as jest.Mock).mockResolvedValue({ executionStarted: true });
|
||||
|
||||
app.use(ChatPlugin, options);
|
||||
|
||||
const chatStore = app.config.globalProperties.$chat;
|
||||
|
||||
const result = await chatStore.sendMessage('test message');
|
||||
|
||||
expect(result).toEqual({ executionStarted: true });
|
||||
});
|
||||
|
||||
it('should return null when sendMessageResponse is null', async () => {
|
||||
const app = createApp({});
|
||||
const options = {
|
||||
webhookUrl: 'test',
|
||||
i18n: {
|
||||
en: {
|
||||
message: 'message',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
footer: 'footer',
|
||||
getStarted: 'getStarted',
|
||||
inputPlaceholder: 'inputPlaceholder',
|
||||
closeButtonTooltip: 'closeButtonTooltip',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(api.sendMessage as jest.Mock).mockResolvedValue({});
|
||||
|
||||
app.use(ChatPlugin, options);
|
||||
|
||||
const chatStore = app.config.globalProperties.$chat;
|
||||
|
||||
const result = await chatStore.sendMessage('test message');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { configure } from '@testing-library/vue';
|
||||
|
||||
configure({ testIdAttribute: 'data-test-id' });
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
vi.fn().mockImplementation(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}));
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createChat } from '@n8n/chat/index';
|
||||
|
||||
export function createTestChat(options: Parameters<typeof createChat>[0] = {}): {
|
||||
unmount: () => void;
|
||||
container: Element;
|
||||
} {
|
||||
const app = createChat(options);
|
||||
|
||||
const container = app._container as Element;
|
||||
const unmount = () => app.unmount();
|
||||
|
||||
return {
|
||||
unmount,
|
||||
container,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@n8n/chat/types';
|
||||
|
||||
export function createFetchResponse<T>(data: T) {
|
||||
const jsonData = JSON.stringify(data);
|
||||
|
||||
return async () =>
|
||||
({
|
||||
json: async () => await new Promise<T>((resolve) => resolve(data)),
|
||||
text: async () => jsonData,
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
}) as unknown as Response;
|
||||
}
|
||||
|
||||
export const createGetLatestMessagesResponse = (
|
||||
data: LoadPreviousSessionResponse['data'] = [],
|
||||
): LoadPreviousSessionResponse => ({ data });
|
||||
|
||||
export const createSendMessageResponse = (
|
||||
output: SendMessageResponse['output'],
|
||||
): SendMessageResponse => ({
|
||||
output,
|
||||
});
|
||||
|
||||
export function createMockStreamingFetchResponse(
|
||||
chunks: Array<{
|
||||
type: string;
|
||||
content?: string;
|
||||
metadata?: { nodeId: string; nodeName: string; timestamp: number };
|
||||
}>,
|
||||
) {
|
||||
return async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => {
|
||||
const data = JSON.stringify(chunk) + '\n';
|
||||
controller.enqueue(encoder.encode(data));
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers(),
|
||||
} as Response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './create';
|
||||
export * from './fetch';
|
||||
export * from './selectors';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { screen } from '@testing-library/vue';
|
||||
|
||||
import { defaultMountingTarget } from '@n8n/chat/constants';
|
||||
|
||||
export function getMountingTarget(target = defaultMountingTarget) {
|
||||
return document.querySelector(target);
|
||||
}
|
||||
|
||||
export function getChatWindowWrapper() {
|
||||
return document.querySelector('.chat-window-wrapper');
|
||||
}
|
||||
|
||||
export function getChatWindowToggle() {
|
||||
return document.querySelector('.chat-window-toggle');
|
||||
}
|
||||
|
||||
export function getChatWrapper() {
|
||||
return document.querySelector('.chat-wrapper');
|
||||
}
|
||||
|
||||
export function getChatMessages() {
|
||||
return document.querySelectorAll('.chat-message:not(.chat-message-typing)');
|
||||
}
|
||||
|
||||
export function getChatMessage(index: number) {
|
||||
const messages = getChatMessages();
|
||||
return index < 0 ? messages[messages.length + index] : messages[index];
|
||||
}
|
||||
|
||||
export function getChatMessageByText(text: string) {
|
||||
return screen.queryByText(text, {
|
||||
selector: '.chat-message:not(.chat-message-typing) .chat-message-markdown p',
|
||||
});
|
||||
}
|
||||
|
||||
export function getChatMessageTyping() {
|
||||
return document.querySelector('.chat-message-typing');
|
||||
}
|
||||
|
||||
export function getGetStartedButton() {
|
||||
return document.querySelector('.chat-get-started .chat-button');
|
||||
}
|
||||
|
||||
export function getChatInput() {
|
||||
return document.querySelector('.chat-input');
|
||||
}
|
||||
|
||||
export function getChatInputTextarea() {
|
||||
return document.querySelector('.chat-input textarea');
|
||||
}
|
||||
|
||||
export function getChatInputSendButton() {
|
||||
return document.querySelector('.chat-input .chat-input-send-button');
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChatMessageText } from '@n8n/chat/types';
|
||||
import {
|
||||
StreamingMessageManager,
|
||||
createBotMessage,
|
||||
updateMessageInArray,
|
||||
} from '@n8n/chat/utils/streaming';
|
||||
|
||||
describe('StreamingMessageManager', () => {
|
||||
it('should initialize runs correctly', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
const message1 = manager.initializeRun('node-1', 0);
|
||||
const message2 = manager.initializeRun('node-1', 1);
|
||||
|
||||
expect(manager.getRunCount()).toBe(2);
|
||||
expect(message1.id).toBeDefined();
|
||||
expect(message2.id).toBeDefined();
|
||||
expect(message1.id).not.toBe(message2.id);
|
||||
});
|
||||
|
||||
it('should create separate messages for different runs', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
// Initialize two different runs
|
||||
const message1 = manager.addRunToActive('node-1', 0);
|
||||
const message2 = manager.addRunToActive('node-1', 1);
|
||||
|
||||
expect(manager.getRunCount()).toBe(2);
|
||||
expect(message1.id).not.toBe(message2.id);
|
||||
|
||||
// Add chunks to different runs
|
||||
const result1 = manager.addChunkToRun('node-1', 'Run 0 content', 0);
|
||||
const result2 = manager.addChunkToRun('node-1', 'Run 1 content', 1);
|
||||
|
||||
expect(result1?.text).toBe('Run 0 content');
|
||||
expect(result2?.text).toBe('Run 1 content');
|
||||
expect(result1?.id).toBe(message1.id);
|
||||
expect(result2?.id).toBe(message2.id);
|
||||
});
|
||||
|
||||
it('should accumulate chunks within the same run', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
const message = manager.addRunToActive('node-1', 0);
|
||||
|
||||
manager.addChunkToRun('node-1', 'Hello ', 0);
|
||||
const result = manager.addChunkToRun('node-1', 'World!', 0);
|
||||
|
||||
expect(result?.text).toBe('Hello World!');
|
||||
expect(result?.id).toBe(message.id);
|
||||
});
|
||||
|
||||
it('should handle runs without runIndex (backward compatibility)', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
const message = manager.addRunToActive('node-1');
|
||||
const result = manager.addChunkToRun('node-1', 'Single run content');
|
||||
|
||||
expect(result?.text).toBe('Single run content');
|
||||
expect(result?.id).toBe(message.id);
|
||||
expect(manager.getRunCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should track active runs correctly', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
manager.addRunToActive('node-1', 0);
|
||||
manager.addRunToActive('node-1', 1);
|
||||
|
||||
expect(manager.getRunCount()).toBe(2);
|
||||
|
||||
manager.removeRunFromActive('node-1', 0);
|
||||
expect(manager.areAllRunsComplete()).toBe(false);
|
||||
|
||||
manager.removeRunFromActive('node-1', 1);
|
||||
expect(manager.areAllRunsComplete()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return all messages in order', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
const message1 = manager.addRunToActive('node-1', 0);
|
||||
const message2 = manager.addRunToActive('node-1', 1);
|
||||
const message3 = manager.addRunToActive('node-2', 0);
|
||||
|
||||
const allMessages = manager.getAllMessages();
|
||||
|
||||
expect(allMessages).toHaveLength(3);
|
||||
expect(allMessages[0].id).toBe(message1.id);
|
||||
expect(allMessages[1].id).toBe(message2.id);
|
||||
expect(allMessages[2].id).toBe(message3.id);
|
||||
});
|
||||
|
||||
it('should reset correctly', () => {
|
||||
const manager = new StreamingMessageManager();
|
||||
|
||||
manager.addRunToActive('node-1', 0);
|
||||
manager.addRunToActive('node-1', 1);
|
||||
manager.addChunkToRun('node-1', 'test', 0);
|
||||
|
||||
expect(manager.getRunCount()).toBe(2);
|
||||
|
||||
manager.reset();
|
||||
|
||||
expect(manager.getRunCount()).toBe(0);
|
||||
expect(manager.getAllMessages()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBotMessage', () => {
|
||||
it('should create a bot message with default values', () => {
|
||||
const message = createBotMessage();
|
||||
|
||||
expect(message.type).toBe('text');
|
||||
expect(message.text).toBe('');
|
||||
expect(message.sender).toBe('bot');
|
||||
expect(message.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a bot message with custom id', () => {
|
||||
const customId = 'custom-id-123';
|
||||
const message = createBotMessage(customId);
|
||||
|
||||
expect(message.id).toBe(customId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMessageInArray', () => {
|
||||
it('should update message in array', () => {
|
||||
const messages: ChatMessageText[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
sender: 'bot',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'text',
|
||||
text: 'World',
|
||||
sender: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
const updatedMessage: ChatMessageText = {
|
||||
id: 'msg-1',
|
||||
type: 'text',
|
||||
text: 'Hello Updated',
|
||||
sender: 'bot',
|
||||
};
|
||||
|
||||
updateMessageInArray(messages, 'msg-1', updatedMessage);
|
||||
|
||||
expect(messages[0].text).toBe('Hello Updated');
|
||||
expect(messages[1].text).toBe('World'); // Should remain unchanged
|
||||
});
|
||||
|
||||
it('should throw error on non-existent message id', () => {
|
||||
const messages: ChatMessageText[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
sender: 'bot',
|
||||
},
|
||||
];
|
||||
|
||||
const updatedMessage: ChatMessageText = {
|
||||
id: 'non-existent',
|
||||
type: 'text',
|
||||
text: 'Should not be added',
|
||||
sender: 'bot',
|
||||
};
|
||||
|
||||
expect(() => updateMessageInArray(messages, 'non-existent', updatedMessage)).toThrow(
|
||||
"Can't update message. No message with id non-existent found",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { ref, type Ref } from 'vue';
|
||||
|
||||
import type { ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
|
||||
import { StreamingMessageManager } from '@n8n/chat/utils/streaming';
|
||||
import {
|
||||
handleStreamingChunk,
|
||||
handleNodeStart,
|
||||
handleNodeComplete,
|
||||
} from '@n8n/chat/utils/streamingHandlers';
|
||||
|
||||
// Mock the chatEventBus
|
||||
vi.mock('@n8n/chat/event-buses', () => ({
|
||||
chatEventBus: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('streamingHandlers', () => {
|
||||
let messages: Ref<ChatMessage[]>;
|
||||
let receivedMessage: Ref<ChatMessageText | null>;
|
||||
let streamingManager: StreamingMessageManager;
|
||||
|
||||
beforeEach(() => {
|
||||
messages = ref<ChatMessage[]>([]);
|
||||
receivedMessage = ref<ChatMessageText | null>(null);
|
||||
streamingManager = new StreamingMessageManager();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleStreamingChunk', () => {
|
||||
it('should handle single-node streaming (no nodeId)', () => {
|
||||
handleStreamingChunk('Hello', undefined, streamingManager, receivedMessage, messages);
|
||||
|
||||
expect(receivedMessage.value).toBeDefined();
|
||||
expect(receivedMessage.value?.text).toBe('Hello');
|
||||
expect(messages.value).toHaveLength(1);
|
||||
|
||||
handleStreamingChunk(' World!', undefined, streamingManager, receivedMessage, messages);
|
||||
|
||||
expect(receivedMessage.value?.text).toBe('Hello World!');
|
||||
expect(messages.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle streaming with separate messages per runIndex', () => {
|
||||
// Start the runs (doesn't create messages yet)
|
||||
handleNodeStart('node-1', streamingManager, 0);
|
||||
handleNodeStart('node-1', streamingManager, 1);
|
||||
|
||||
expect(messages.value).toHaveLength(0); // No messages created yet
|
||||
|
||||
// Now handle chunks for different runs - this will create the messages
|
||||
handleStreamingChunk(
|
||||
'Run 0 content',
|
||||
'node-1',
|
||||
streamingManager,
|
||||
receivedMessage,
|
||||
messages,
|
||||
0,
|
||||
);
|
||||
handleStreamingChunk(
|
||||
'Run 1 content',
|
||||
'node-1',
|
||||
streamingManager,
|
||||
receivedMessage,
|
||||
messages,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(messages.value).toHaveLength(2); // Messages created on first chunk
|
||||
|
||||
// Check that we have two separate messages with different content
|
||||
const message1 = messages.value[0] as ChatMessageText;
|
||||
const message2 = messages.value[1] as ChatMessageText;
|
||||
|
||||
expect(message1.text).toBe('Run 0 content');
|
||||
expect(message2.text).toBe('Run 1 content');
|
||||
expect(message1.id).not.toBe(message2.id);
|
||||
});
|
||||
|
||||
it('should accumulate chunks within the same run', () => {
|
||||
// Start a run (doesn't create message yet)
|
||||
handleNodeStart('node-1', streamingManager, 0);
|
||||
|
||||
expect(messages.value).toHaveLength(0);
|
||||
|
||||
// Add multiple chunks to the same run - message created on first chunk
|
||||
handleStreamingChunk('Hello ', 'node-1', streamingManager, receivedMessage, messages, 0);
|
||||
expect(messages.value).toHaveLength(1);
|
||||
|
||||
handleStreamingChunk('World!', 'node-1', streamingManager, receivedMessage, messages, 0);
|
||||
|
||||
const message = messages.value[0] as ChatMessageText;
|
||||
expect(message.text).toBe('Hello World!');
|
||||
expect(messages.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
// Simulate an error by passing invalid parameters
|
||||
const invalidStreamingManager = null as unknown as StreamingMessageManager;
|
||||
|
||||
expect(() => {
|
||||
handleStreamingChunk('test', 'node-1', invalidStreamingManager, receivedMessage, messages);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleNodeStart', () => {
|
||||
it('should register runs but not create messages yet', () => {
|
||||
handleNodeStart('node-1', streamingManager, 0);
|
||||
handleNodeStart('node-1', streamingManager, 1);
|
||||
|
||||
// No messages created yet - they'll be created on first chunk
|
||||
expect(messages.value).toHaveLength(0);
|
||||
|
||||
// But runs should be registered as active
|
||||
// We can verify this by checking that chunks will create messages
|
||||
handleStreamingChunk('test', 'node-1', streamingManager, receivedMessage, messages, 0);
|
||||
expect(messages.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle runs without runIndex', () => {
|
||||
handleNodeStart('node-1', streamingManager);
|
||||
|
||||
expect(messages.value).toHaveLength(0);
|
||||
|
||||
// Verify run is registered by adding a chunk
|
||||
handleStreamingChunk('test', 'node-1', streamingManager, receivedMessage, messages);
|
||||
expect(messages.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
const invalidStreamingManager = null as unknown as StreamingMessageManager;
|
||||
|
||||
expect(() => {
|
||||
handleNodeStart('node-1', invalidStreamingManager);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleNodeComplete', () => {
|
||||
const mockOptions: ChatOptions = {
|
||||
webhookUrl: 'http://test.com',
|
||||
i18n: {
|
||||
en: {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
footer: '',
|
||||
getStarted: '',
|
||||
inputPlaceholder: '',
|
||||
closeButtonTooltip: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const userMessage = 'test message';
|
||||
|
||||
it('should mark run as complete', async () => {
|
||||
// Setup initial state
|
||||
streamingManager.addRunToActive('node-1', 0);
|
||||
|
||||
await handleNodeComplete('node-1', streamingManager, 0, userMessage, mockOptions, messages);
|
||||
|
||||
expect(streamingManager.areAllRunsComplete()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple runs completion', async () => {
|
||||
// Setup two runs
|
||||
streamingManager.addRunToActive('node-1', 0);
|
||||
streamingManager.addRunToActive('node-1', 1);
|
||||
|
||||
// Complete first run
|
||||
await handleNodeComplete('node-1', streamingManager, 0, userMessage, mockOptions, messages);
|
||||
expect(streamingManager.areAllRunsComplete()).toBe(false);
|
||||
|
||||
// Complete second run
|
||||
await handleNodeComplete('node-1', streamingManager, 1, userMessage, mockOptions, messages);
|
||||
expect(streamingManager.areAllRunsComplete()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle runs without runIndex', async () => {
|
||||
streamingManager.addRunToActive('node-1');
|
||||
|
||||
await handleNodeComplete(
|
||||
'node-1',
|
||||
streamingManager,
|
||||
undefined,
|
||||
userMessage,
|
||||
mockOptions,
|
||||
messages,
|
||||
);
|
||||
|
||||
expect(streamingManager.areAllRunsComplete()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const invalidStreamingManager = null as unknown as StreamingMessageManager;
|
||||
|
||||
await expect(
|
||||
handleNodeComplete(
|
||||
'node-1',
|
||||
invalidStreamingManager,
|
||||
undefined,
|
||||
userMessage,
|
||||
mockOptions,
|
||||
messages,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should call afterMessageSent hook when provided', async () => {
|
||||
const afterMessageSent = vi.fn();
|
||||
const optionsWithHook: ChatOptions = {
|
||||
...mockOptions,
|
||||
afterMessageSent,
|
||||
};
|
||||
|
||||
// Setup and add a message
|
||||
streamingManager.addRunToActive('node-1', 0);
|
||||
const message = streamingManager.getRunMessage('node-1', 0);
|
||||
|
||||
await handleNodeComplete(
|
||||
'node-1',
|
||||
streamingManager,
|
||||
0,
|
||||
userMessage,
|
||||
optionsWithHook,
|
||||
messages,
|
||||
);
|
||||
|
||||
expect(afterMessageSent).toHaveBeenCalledWith(userMessage, {
|
||||
message,
|
||||
hasReceivedChunks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call afterMessageSent hook if no message exists', async () => {
|
||||
const afterMessageSent = vi.fn();
|
||||
const optionsWithHook: ChatOptions = {
|
||||
...mockOptions,
|
||||
afterMessageSent,
|
||||
};
|
||||
|
||||
// Don't add any message
|
||||
await handleNodeComplete(
|
||||
'node-1',
|
||||
streamingManager,
|
||||
0,
|
||||
userMessage,
|
||||
optionsWithHook,
|
||||
messages,
|
||||
);
|
||||
|
||||
expect(afterMessageSent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MessageComponentKey } from '@n8n/chat/constants';
|
||||
import { parseBotChatMessageContent, shouldBlockUserInput } from '@n8n/chat/utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('parseBotChatMessageContent', () => {
|
||||
it('should return a string for a non-JSON message', () => {
|
||||
const message = parseBotChatMessageContent('test');
|
||||
expect(message).toEqual({
|
||||
id: expect.any(String),
|
||||
sender: 'bot',
|
||||
text: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse a message with buttons', () => {
|
||||
const jsonMessage = {
|
||||
type: 'with-buttons',
|
||||
text: 'test',
|
||||
buttons: [{ text: 'Approve', link: 'https://yes.com', type: 'primary' }],
|
||||
blockUserInput: true,
|
||||
};
|
||||
|
||||
const message = parseBotChatMessageContent(JSON.stringify(jsonMessage));
|
||||
|
||||
expect(message).toEqual({
|
||||
id: expect.any(String),
|
||||
sender: 'bot',
|
||||
type: 'component',
|
||||
key: MessageComponentKey.WITH_BUTTONS,
|
||||
arguments: {
|
||||
text: 'test',
|
||||
buttons: [{ text: 'Approve', link: 'https://yes.com', type: 'primary' }],
|
||||
blockUserInput: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldBlockUserInput', () => {
|
||||
it('should return true for message with buttons and when blockUserInput is true', () => {
|
||||
const message = {
|
||||
id: '1',
|
||||
sender: 'bot' as const,
|
||||
type: 'component' as const,
|
||||
key: MessageComponentKey.WITH_BUTTONS,
|
||||
arguments: { blockUserInput: true },
|
||||
};
|
||||
|
||||
const result = shouldBlockUserInput(message);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for message with buttons and when blockUserInput is false', () => {
|
||||
const message = {
|
||||
id: '1',
|
||||
sender: 'bot' as const,
|
||||
type: 'component' as const,
|
||||
key: MessageComponentKey.WITH_BUTTONS,
|
||||
arguments: { blockUserInput: false },
|
||||
};
|
||||
|
||||
const result = shouldBlockUserInput(message);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for regular message', () => {
|
||||
const message = {
|
||||
id: '1',
|
||||
sender: 'bot' as const,
|
||||
text: 'test',
|
||||
};
|
||||
|
||||
const result = shouldBlockUserInput(message);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user