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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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);
});
});
});