mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
587 lines
17 KiB
TypeScript
587 lines
17 KiB
TypeScript
import { setActivePinia } from 'pinia';
|
|
import { createTestingPinia } from '@pinia/testing';
|
|
import { waitFor } from '@testing-library/vue';
|
|
import { userEvent } from '@testing-library/user-event';
|
|
import { createRouter, createWebHistory } from 'vue-router';
|
|
import { computed, ref } from 'vue';
|
|
import { NodeConnectionType } from 'n8n-workflow';
|
|
|
|
import CanvasChat from './CanvasChat.vue';
|
|
import { createComponentRenderer } from '@/__tests__/render';
|
|
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
|
import { mockedStore } from '@/__tests__/utils';
|
|
import { STORES } from '@/constants';
|
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
|
|
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
import { useUIStore } from '@/stores/ui.store';
|
|
import { useCanvasStore } from '@/stores/canvas.store';
|
|
import * as useChatMessaging from './composables/useChatMessaging';
|
|
import * as useChatTrigger from './composables/useChatTrigger';
|
|
import { useToast } from '@/composables/useToast';
|
|
|
|
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
|
import type { ChatMessage } from '@n8n/chat/types';
|
|
|
|
vi.mock('@/composables/useToast', () => {
|
|
const showMessage = vi.fn();
|
|
const showError = vi.fn();
|
|
return {
|
|
useToast: () => {
|
|
return {
|
|
showMessage,
|
|
showError,
|
|
clearAllStickyNotifications: vi.fn(),
|
|
};
|
|
},
|
|
};
|
|
});
|
|
// Test data
|
|
const mockNodes: INodeUi[] = [
|
|
{
|
|
parameters: {
|
|
options: {
|
|
allowFileUploads: true,
|
|
},
|
|
},
|
|
id: 'chat-trigger-id',
|
|
name: 'When chat message received',
|
|
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
|
typeVersion: 1.1,
|
|
position: [740, 860],
|
|
webhookId: 'webhook-id',
|
|
},
|
|
{
|
|
parameters: {},
|
|
id: 'agent-id',
|
|
name: 'AI Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
typeVersion: 1.7,
|
|
position: [960, 860],
|
|
},
|
|
];
|
|
|
|
const mockConnections = {
|
|
'When chat message received': {
|
|
main: [
|
|
[
|
|
{
|
|
node: 'AI Agent',
|
|
type: NodeConnectionType.Main,
|
|
index: 0,
|
|
},
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockWorkflowExecution = {
|
|
data: {
|
|
resultData: {
|
|
runData: {
|
|
'AI Agent': [
|
|
{
|
|
data: {
|
|
main: [[{ json: { output: 'AI response message' } }]],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
lastNodeExecuted: 'AI Agent',
|
|
},
|
|
},
|
|
};
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(),
|
|
routes: [],
|
|
});
|
|
|
|
describe('CanvasChat', () => {
|
|
const renderComponent = createComponentRenderer(CanvasChat, {
|
|
global: {
|
|
provide: {
|
|
[ChatSymbol as symbol]: {},
|
|
[ChatOptionsSymbol as symbol]: {},
|
|
},
|
|
plugins: [router],
|
|
},
|
|
});
|
|
|
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
|
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
|
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
|
|
|
|
beforeEach(() => {
|
|
const pinia = createTestingPinia({
|
|
initialState: {
|
|
[STORES.WORKFLOWS]: {
|
|
workflow: {
|
|
nodes: mockNodes,
|
|
connections: mockConnections,
|
|
},
|
|
},
|
|
[STORES.UI]: {
|
|
chatPanelOpen: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
setActivePinia(pinia);
|
|
|
|
workflowsStore = mockedStore(useWorkflowsStore);
|
|
uiStore = mockedStore(useUIStore);
|
|
canvasStore = mockedStore(useCanvasStore);
|
|
|
|
// Setup default mocks
|
|
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
|
createTestWorkflowObject({
|
|
nodes: mockNodes,
|
|
connections: mockConnections,
|
|
}),
|
|
);
|
|
workflowsStore.getNodeByName.mockImplementation(
|
|
(name) => mockNodes.find((node) => node.name === name) ?? null,
|
|
);
|
|
workflowsStore.isChatPanelOpen = true;
|
|
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
|
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('rendering', () => {
|
|
it('should render chat when panel is open', () => {
|
|
const { getByTestId } = renderComponent();
|
|
expect(getByTestId('canvas-chat')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render chat when panel is closed', async () => {
|
|
workflowsStore.isChatPanelOpen = false;
|
|
const { queryByTestId } = renderComponent();
|
|
await waitFor(() => {
|
|
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show correct input placeholder', async () => {
|
|
const { findByTestId } = renderComponent();
|
|
expect(await findByTestId('chat-input')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('message handling', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(chatEventBus, 'emit');
|
|
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
|
|
});
|
|
|
|
it('should send message and show response', async () => {
|
|
const { findByTestId, findByText } = renderComponent();
|
|
|
|
// Send message
|
|
const input = await findByTestId('chat-input');
|
|
await userEvent.type(input, 'Hello AI!');
|
|
await userEvent.keyboard('{Enter}');
|
|
|
|
// Verify message and response
|
|
expect(await findByText('Hello AI!')).toBeInTheDocument();
|
|
await waitFor(async () => {
|
|
expect(await findByText('AI response message')).toBeInTheDocument();
|
|
});
|
|
|
|
// Verify workflow execution
|
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
runData: {
|
|
'When chat message received': [
|
|
{
|
|
data: {
|
|
main: [
|
|
[
|
|
{
|
|
json: {
|
|
action: 'sendMessage',
|
|
chatInput: 'Hello AI!',
|
|
sessionId: expect.any(String),
|
|
},
|
|
},
|
|
],
|
|
],
|
|
},
|
|
executionStatus: 'success',
|
|
executionTime: 0,
|
|
source: [null],
|
|
startTime: expect.any(Number),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should show loading state during message processing', async () => {
|
|
const { findByTestId, queryByTestId } = renderComponent();
|
|
|
|
// Send message
|
|
const input = await findByTestId('chat-input');
|
|
await userEvent.type(input, 'Test message');
|
|
await userEvent.keyboard('{Enter}');
|
|
|
|
// Verify loading states
|
|
uiStore.isActionActive = { workflowRunning: true };
|
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
|
|
|
|
uiStore.isActionActive = { workflowRunning: false };
|
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('should handle workflow execution errors', async () => {
|
|
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
|
|
|
|
const { findByTestId } = renderComponent();
|
|
|
|
const input = await findByTestId('chat-input');
|
|
await userEvent.type(input, 'Hello AI!');
|
|
await userEvent.keyboard('{Enter}');
|
|
|
|
const toast = useToast();
|
|
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
|
|
});
|
|
});
|
|
|
|
describe('session management', () => {
|
|
const mockMessages: ChatMessage[] = [
|
|
{
|
|
id: '1',
|
|
text: 'Existing message',
|
|
sender: 'user',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
|
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
|
sendMessage: vi.fn(),
|
|
extractResponseMessage: vi.fn(),
|
|
previousMessageIndex: ref(0),
|
|
waitForExecution: vi.fn(),
|
|
});
|
|
});
|
|
|
|
it('should allow copying session ID', async () => {
|
|
const clipboardSpy = vi.fn();
|
|
document.execCommand = clipboardSpy;
|
|
const { getByTestId } = renderComponent();
|
|
|
|
await userEvent.click(getByTestId('chat-session-id'));
|
|
const toast = useToast();
|
|
expect(clipboardSpy).toHaveBeenCalledWith('copy');
|
|
expect(toast.showMessage).toHaveBeenCalledWith({
|
|
message: '',
|
|
title: 'Copied to clipboard',
|
|
type: 'success',
|
|
});
|
|
});
|
|
|
|
it('should refresh session with confirmation when messages exist', async () => {
|
|
const { getByTestId, getByRole } = renderComponent();
|
|
|
|
const originalSessionId = getByTestId('chat-session-id').textContent;
|
|
await userEvent.click(getByTestId('refresh-session-button'));
|
|
|
|
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
|
|
|
|
if (!confirmButton) throw new Error('Confirm button not found');
|
|
await userEvent.click(confirmButton);
|
|
|
|
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
|
|
});
|
|
});
|
|
|
|
describe('resize functionality', () => {
|
|
it('should handle panel resizing', async () => {
|
|
const { container } = renderComponent();
|
|
|
|
const resizeWrapper = container.querySelector('.resizeWrapper');
|
|
if (!resizeWrapper) throw new Error('Resize wrapper not found');
|
|
|
|
await userEvent.pointer([
|
|
{ target: resizeWrapper, coords: { clientX: 0, clientY: 0 } },
|
|
{ coords: { clientX: 0, clientY: 100 } },
|
|
]);
|
|
|
|
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should persist resize dimensions', () => {
|
|
const mockStorage = {
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
};
|
|
Object.defineProperty(window, 'localStorage', { value: mockStorage });
|
|
|
|
renderComponent();
|
|
|
|
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_HEIGHT');
|
|
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_WIDTH');
|
|
});
|
|
});
|
|
|
|
describe('file handling', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
|
getChatMessages: vi.fn().mockReturnValue([]),
|
|
sendMessage: vi.fn(),
|
|
extractResponseMessage: vi.fn(),
|
|
previousMessageIndex: ref(0),
|
|
waitForExecution: vi.fn(),
|
|
});
|
|
|
|
workflowsStore.isChatPanelOpen = true;
|
|
workflowsStore.allowFileUploads = true;
|
|
});
|
|
|
|
it('should enable file uploads when allowed by chat trigger node', async () => {
|
|
const allowFileUploads = ref(true);
|
|
const original = useChatTrigger.useChatTrigger;
|
|
vi.spyOn(useChatTrigger, 'useChatTrigger').mockImplementation((...args) => ({
|
|
...original(...args),
|
|
allowFileUploads: computed(() => allowFileUploads.value),
|
|
}));
|
|
const { getByTestId } = renderComponent();
|
|
|
|
const chatPanel = getByTestId('canvas-chat');
|
|
expect(chatPanel).toBeInTheDocument();
|
|
|
|
const fileInput = getByTestId('chat-attach-file-button');
|
|
expect(fileInput).toBeInTheDocument();
|
|
|
|
allowFileUploads.value = false;
|
|
await waitFor(() => {
|
|
expect(fileInput).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('message history handling', () => {
|
|
it('should properly navigate through message history with wrap-around', async () => {
|
|
const messages = ['Message 1', 'Message 2', 'Message 3'];
|
|
workflowsStore.getPastChatMessages = messages;
|
|
|
|
const { findByTestId } = renderComponent();
|
|
const input = await findByTestId('chat-input');
|
|
|
|
// First up should show most recent message
|
|
await userEvent.keyboard('{ArrowUp}');
|
|
expect(input).toHaveValue('Message 3');
|
|
|
|
// Second up should show second most recent
|
|
await userEvent.keyboard('{ArrowUp}');
|
|
expect(input).toHaveValue('Message 2');
|
|
|
|
// Third up should show oldest message
|
|
await userEvent.keyboard('{ArrowUp}');
|
|
expect(input).toHaveValue('Message 1');
|
|
|
|
// Fourth up should wrap around to most recent
|
|
await userEvent.keyboard('{ArrowUp}');
|
|
expect(input).toHaveValue('Message 3');
|
|
|
|
// Down arrow should go in reverse
|
|
await userEvent.keyboard('{ArrowDown}');
|
|
expect(input).toHaveValue('Message 1');
|
|
});
|
|
|
|
it('should reset message history navigation on new input', async () => {
|
|
workflowsStore.getPastChatMessages = ['Message 1', 'Message 2'];
|
|
const { findByTestId } = renderComponent();
|
|
const input = await findByTestId('chat-input');
|
|
|
|
// Navigate to oldest message
|
|
await userEvent.keyboard('{ArrowUp}'); // Most recent
|
|
await userEvent.keyboard('{ArrowUp}'); // Oldest
|
|
expect(input).toHaveValue('Message 1');
|
|
|
|
await userEvent.type(input, 'New message');
|
|
await userEvent.keyboard('{Enter}');
|
|
|
|
await userEvent.keyboard('{ArrowUp}');
|
|
expect(input).toHaveValue('Message 2');
|
|
});
|
|
});
|
|
|
|
describe('message reuse and repost', () => {
|
|
const sendMessageSpy = vi.fn();
|
|
beforeEach(() => {
|
|
const mockMessages: ChatMessage[] = [
|
|
{
|
|
id: '1',
|
|
text: 'Original message',
|
|
sender: 'user',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: '2',
|
|
text: 'AI response',
|
|
sender: 'bot',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
|
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
|
sendMessage: sendMessageSpy,
|
|
extractResponseMessage: vi.fn(),
|
|
previousMessageIndex: ref(0),
|
|
waitForExecution: vi.fn(),
|
|
});
|
|
workflowsStore.messages = mockMessages;
|
|
});
|
|
|
|
it('should repost user message with new execution', async () => {
|
|
const { findByTestId } = renderComponent();
|
|
const repostButton = await findByTestId('repost-message-button');
|
|
|
|
await userEvent.click(repostButton);
|
|
|
|
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
|
|
// expect.objectContaining({
|
|
// runData: expect.objectContaining({
|
|
// 'When chat message received': expect.arrayContaining([
|
|
// expect.objectContaining({
|
|
// data: expect.objectContaining({
|
|
// main: expect.arrayContaining([
|
|
// expect.arrayContaining([
|
|
// expect.objectContaining({
|
|
// json: expect.objectContaining({
|
|
// chatInput: 'Original message',
|
|
// }),
|
|
// }),
|
|
// ]),
|
|
// ]),
|
|
// }),
|
|
// }),
|
|
// ]),
|
|
// }),
|
|
// }),
|
|
// );
|
|
});
|
|
|
|
it('should show message options only for appropriate messages', async () => {
|
|
const { findByText, container } = renderComponent();
|
|
|
|
await findByText('Original message');
|
|
const userMessage = container.querySelector('.chat-message-from-user');
|
|
expect(
|
|
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
|
).toBeInTheDocument();
|
|
|
|
await findByText('AI response');
|
|
const botMessage = container.querySelector('.chat-message-from-bot');
|
|
expect(
|
|
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('execution handling', () => {
|
|
it('should update UI when execution is completed', async () => {
|
|
const { findByTestId, queryByTestId } = renderComponent();
|
|
|
|
// Start execution
|
|
const input = await findByTestId('chat-input');
|
|
await userEvent.type(input, 'Test message');
|
|
await userEvent.keyboard('{Enter}');
|
|
|
|
// Simulate execution completion
|
|
uiStore.isActionActive = { workflowRunning: true };
|
|
await waitFor(() => {
|
|
expect(queryByTestId('chat-message-typing')).toBeInTheDocument();
|
|
});
|
|
|
|
uiStore.isActionActive = { workflowRunning: false };
|
|
workflowsStore.setWorkflowExecutionData(
|
|
mockWorkflowExecution as unknown as IExecutionResponse,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('panel state synchronization', () => {
|
|
it('should update canvas height when chat or logs panel state changes', async () => {
|
|
renderComponent();
|
|
|
|
// Toggle logs panel
|
|
workflowsStore.isLogsPanelOpen = true;
|
|
await waitFor(() => {
|
|
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
|
});
|
|
|
|
// Close chat panel
|
|
workflowsStore.isChatPanelOpen = false;
|
|
await waitFor(() => {
|
|
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
|
});
|
|
});
|
|
|
|
it('should preserve panel state across component remounts', async () => {
|
|
const { unmount, rerender } = renderComponent();
|
|
|
|
// Set initial state
|
|
workflowsStore.isChatPanelOpen = true;
|
|
workflowsStore.isLogsPanelOpen = true;
|
|
|
|
// Unmount and remount
|
|
unmount();
|
|
await rerender({});
|
|
|
|
expect(workflowsStore.isChatPanelOpen).toBe(true);
|
|
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('keyboard shortcuts', () => {
|
|
it('should handle Enter key with modifier to start new line', async () => {
|
|
const { findByTestId } = renderComponent();
|
|
|
|
const input = await findByTestId('chat-input');
|
|
await userEvent.type(input, 'Line 1');
|
|
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
|
await userEvent.type(input, 'Line 2');
|
|
|
|
expect(input).toHaveValue('Line 1\nLine 2');
|
|
});
|
|
});
|
|
|
|
describe('chat synchronization', () => {
|
|
it('should load initial chat history when first opening panel', async () => {
|
|
const getChatMessagesSpy = vi.fn().mockReturnValue(['Previous message']);
|
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
|
...vi.fn()(),
|
|
getChatMessages: getChatMessagesSpy,
|
|
});
|
|
|
|
workflowsStore.isChatPanelOpen = false;
|
|
const { rerender } = renderComponent();
|
|
|
|
workflowsStore.isChatPanelOpen = true;
|
|
await rerender({});
|
|
|
|
expect(getChatMessagesSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|