Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/stores/builder.store.test.ts
2025-08-14 10:05:36 +02:00

782 lines
24 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
import { usePostHog } from './posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '../__tests__/defaults';
import merge from 'lodash/merge';
import { DEFAULT_POSTHOG_SETTINGS } from './posthog.test';
import { WORKFLOW_BUILDER_EXPERIMENT } from '@/constants';
import { reactive } from 'vue';
import * as chatAPI from '@/api/ai';
import * as telemetryModule from '@/composables/useTelemetry';
import type { Telemetry } from '@/plugins/telemetry';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
// Mock useI18n to return the keys instead of translations
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
let settingsStore: ReturnType<typeof useSettingsStore>;
let posthogStore: ReturnType<typeof usePostHog>;
const apiSpy = vi.spyOn(chatAPI, 'chatWithBuilder');
const track = vi.fn();
const spy = vi.spyOn(telemetryModule, 'useTelemetry');
spy.mockImplementation(
() =>
({
track,
}) as unknown as Telemetry,
);
const setAssistantEnabled = (enabled: boolean) => {
settingsStore.setSettings(
merge({}, defaultSettings, {
aiAssistant: { enabled },
}),
);
};
const currentRouteName = ENABLED_VIEWS[0];
vi.mock('vue-router', () => ({
useRoute: vi.fn(() =>
reactive({
path: '/',
params: {},
name: currentRouteName,
}),
),
useRouter: vi.fn(),
RouterLink: vi.fn(),
}));
describe('AI Builder store', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
settingsStore = useSettingsStore();
settingsStore.setSettings(
merge({}, defaultSettings, {
posthog: DEFAULT_POSTHOG_SETTINGS,
}),
);
window.posthog = {
init: () => {},
identify: () => {},
};
posthogStore = usePostHog();
posthogStore.init();
track.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
it('initializes with default values', () => {
const builderStore = useBuilderStore();
expect(builderStore.chatWidth).toBe(DEFAULT_CHAT_WIDTH);
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.chatWindowOpen).toBe(false);
expect(builderStore.streaming).toBe(false);
});
it('can change chat width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(400);
expect(builderStore.chatWidth).toBe(400);
});
it('should not allow chat width to be less than the minimal width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(100);
expect(builderStore.chatWidth).toBe(MIN_CHAT_WIDTH);
});
it('should not allow chat width to be more than the maximal width', () => {
const builderStore = useBuilderStore();
builderStore.updateWindowWidth(2000);
expect(builderStore.chatWidth).toBe(MAX_CHAT_WIDTH);
});
it('should open chat window', async () => {
const builderStore = useBuilderStore();
await builderStore.openChat();
expect(builderStore.chatWindowOpen).toBe(true);
});
it('should close chat window', () => {
const builderStore = useBuilderStore();
builderStore.closeChat();
expect(builderStore.chatWindowOpen).toBe(false);
});
it('can process a simple assistant message through API', async () => {
const builderStore = useBuilderStore();
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Hello!',
},
],
sessionId: 'test-session',
});
onDone();
});
builderStore.sendChatMessage({ text: 'Hi' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1]).toMatchObject({
type: 'text',
role: 'assistant',
content: 'Hello!',
read: false,
});
});
it('can process a workflow-updated message through API', async () => {
const builderStore = useBuilderStore();
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'workflow-updated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
},
],
sessionId: 'test-session',
});
onDone();
});
builderStore.sendChatMessage({ text: 'Create workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(builderStore.chatMessages[1]).toMatchObject({
type: 'workflow-updated',
role: 'assistant',
codeSnippet: '{"nodes":[],"connections":[]}',
read: false,
});
// Verify workflow messages are accessible via computed property
expect(builderStore.workflowMessages.length).toBe(1);
});
it('should show processing results message when tools complete', async () => {
vi.useFakeTimers();
const builderStore = useBuilderStore();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let onMessageCallback: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let onDoneCallback: any;
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessageCallback = onMessage;
onDoneCallback = onDone;
});
builderStore.sendChatMessage({ text: 'Add nodes and connect them' });
// Initially shows "aiAssistant.thinkingSteps.thinking" from prepareForStreaming
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// First tool starts
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'running',
updates: [{ type: 'input', data: {} }],
},
],
});
// Should show "aiAssistant.thinkingSteps.runningTools"
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Second tool starts (different toolCallId)
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'connect_nodes',
toolCallId: 'call-2',
status: 'running',
updates: [{ type: 'input', data: {} }],
},
],
});
// Still showing "aiAssistant.thinkingSteps.runningTools" with multiple tools
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// First tool completes
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'completed',
updates: [{ type: 'output', data: { success: true } }],
},
],
});
// Still "aiAssistant.thinkingSteps.runningTools" because second tool is still running
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Second tool completes
onMessageCallback({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'connect_nodes',
toolCallId: 'call-2',
status: 'completed',
updates: [{ type: 'output', data: { success: true } }],
},
],
});
// Now should show "aiAssistant.thinkingSteps.processingResults" because all tools completed
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
// Call onDone to stop streaming
onDoneCallback();
// Message should persist after streaming ends
expect(builderStore.streaming).toBe(false);
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
vi.useRealTimers();
});
it('should keep processing message when workflow-updated arrives', async () => {
const builderStore = useBuilderStore();
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
// Tool completes
onMessage({
messages: [
{
type: 'tool',
role: 'assistant',
toolName: 'add_nodes',
toolCallId: 'call-1',
status: 'completed',
updates: [{ type: 'output', data: { success: true } }],
},
],
});
// Workflow update arrives
onMessage({
messages: [
{
type: 'workflow-updated',
role: 'assistant',
codeSnippet: '{"nodes": [], "connections": {}}',
},
],
});
// Call onDone to stop streaming
onDone();
});
builderStore.sendChatMessage({ text: 'Add a node' });
// Should show "aiAssistant.thinkingSteps.processingResults" when tool completes
await vi.waitFor(() =>
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
),
);
// Should still show "aiAssistant.thinkingSteps.processingResults" after workflow-updated
await vi.waitFor(() => expect(builderStore.chatMessages).toHaveLength(3)); // user + tool + workflow
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
// Verify streaming has ended
expect(builderStore.streaming).toBe(false);
});
it('should reset builder chat session', async () => {
const builderStore = useBuilderStore();
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Hello!',
quickReplies: [
{ text: 'Yes', type: 'text' },
{ text: 'No', type: 'text' },
],
},
],
sessionId: 'test-session',
});
onDone();
});
builderStore.sendChatMessage({ text: 'Hi' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
builderStore.resetBuilderChat();
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.assistantThinkingMessage).toBeUndefined();
});
it('should not show builder if disabled in settings', () => {
const builderStore = useBuilderStore();
setAssistantEnabled(false);
expect(builderStore.isAssistantEnabled).toBe(false);
expect(builderStore.canShowAssistant).toBe(false);
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(false);
});
it('should show builder if all conditions are met', () => {
const builderStore = useBuilderStore();
setAssistantEnabled(true);
expect(builderStore.isAssistantEnabled).toBe(true);
expect(builderStore.canShowAssistant).toBe(true);
expect(builderStore.canShowAssistantButtonsOnCanvas).toBe(true);
});
// Split into two separate tests to avoid caching issues with computed properties
it('should return true when experiment flag is set to variant', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.variant);
expect(builderStore.isAIBuilderEnabled).toBe(true);
});
it('should return false when experiment flag is set to control', () => {
const builderStore = useBuilderStore();
vi.spyOn(posthogStore, 'getVariant').mockReturnValue(WORKFLOW_BUILDER_EXPERIMENT.control);
expect(builderStore.isAIBuilderEnabled).toBe(false);
});
it('should initialize builder chat session with prompt', async () => {
const builderStore = useBuilderStore();
const mockSessionId = 'test-session-id';
apiSpy.mockImplementation((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'How can I help you build a workflow?',
},
],
sessionId: mockSessionId,
});
onDone();
});
builderStore.sendChatMessage({ text: 'I want to build a workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(apiSpy).toHaveBeenCalled();
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].role).toBe('assistant');
expect(builderStore.streaming).toBe(false);
});
it('should send a follow-up message in an existing session', async () => {
const builderStore = useBuilderStore();
const mockSessionId = 'test-session-id';
// Setup initial session
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'How can I help you build a workflow?',
},
],
sessionId: mockSessionId,
});
onDone();
});
// Setup follow-up message response
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Here are some workflow ideas',
},
],
sessionId: mockSessionId,
});
onDone();
});
builderStore.sendChatMessage({ text: 'I want to build a workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
// Send a follow-up message
builderStore.sendChatMessage({ text: 'Generate a workflow for me' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(4));
const thirdMessage = builderStore.chatMessages[2] as ChatUI.TextMessage;
const fourthMessage = builderStore.chatMessages[3] as ChatUI.TextMessage;
expect(thirdMessage.role).toBe('user');
expect(thirdMessage.type).toBe('text');
expect(thirdMessage.content).toBe('Generate a workflow for me');
expect(fourthMessage.role).toBe('assistant');
expect(fourthMessage.type).toBe('text');
expect(fourthMessage.content).toBe('Here are some workflow ideas');
});
it('should properly handle errors in chat session', async () => {
const builderStore = useBuilderStore();
// Simulate an error response
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
onError(new Error('An API error occurred'));
});
builderStore.sendChatMessage({ text: 'I want to build a workflow' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('error');
// Error message should have a retry function
const errorMessage = builderStore.chatMessages[1] as ChatUI.ErrorMessage;
expect(errorMessage.retry).toBeDefined();
// Verify streaming state was reset
expect(builderStore.streaming).toBe(false);
// Set up a successful response for the retry
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'I can help you build a workflow',
},
],
sessionId: 'new-session',
});
onDone();
});
// Retry the failed request
if (errorMessage.retry) {
void errorMessage.retry();
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
}
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].type).toBe('text');
expect((builderStore.chatMessages[1] as ChatUI.TextMessage).content).toBe(
'I can help you build a workflow',
);
});
describe('Abort functionality', () => {
it('should create and manage abort controller', () => {
const builderStore = useBuilderStore();
// Initially no abort controller (might be undefined or null)
expect(builderStore.streamingAbortController).toBeFalsy();
// Start streaming creates abort controller
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, _onDone, _onError, _signal) => {
// Simulate successful start of streaming
setTimeout(() => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Processing...',
},
],
sessionId: 'test-session',
});
}, 0);
});
builderStore.sendChatMessage({ text: 'test' });
expect(builderStore.streamingAbortController).not.toBeNull();
expect(builderStore.streamingAbortController).toBeInstanceOf(AbortController);
});
it('should call abort on existing controller when stopStreaming is called', () => {
const builderStore = useBuilderStore();
// First start a request to create an abort controller
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test' });
// Verify controller was created
const controller = builderStore.streamingAbortController;
expect(controller).toBeInstanceOf(AbortController);
// Spy on the abort method
const abortSpy = vi.spyOn(controller!, 'abort');
// Call stopStreaming
builderStore.stopStreaming();
// Verify abort was called
expect(abortSpy).toHaveBeenCalled();
expect(builderStore.streamingAbortController).toBeNull();
expect(builderStore.streaming).toBe(false);
});
it('should handle AbortError gracefully', async () => {
const builderStore = useBuilderStore();
// Simulate an abort error
const abortError = new Error('AbortError');
abortError.name = 'AbortError';
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
onError(abortError);
});
builderStore.sendChatMessage({ text: 'test message' });
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBe(2));
// Should have user message and aborted message
expect(builderStore.chatMessages[0].role).toBe('user');
expect(builderStore.chatMessages[1].role).toBe('assistant');
expect(builderStore.chatMessages[1].type).toBe('text');
expect((builderStore.chatMessages[1] as ChatUI.TextMessage).content).toBe('[Task aborted]');
// Verify streaming state was reset
expect(builderStore.streaming).toBe(false);
expect(builderStore.assistantThinkingMessage).toBeUndefined();
});
it('should abort previous request when sending new message', () => {
const builderStore = useBuilderStore();
// The current implementation prevents sending a new message while streaming
// by checking if streaming.value is true and returning early.
// Mock for first request - keep it pending
apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, _onDone) => {
// Don't call onDone to keep streaming active
setTimeout(() => {
onMessage({
messages: [
{
type: 'message',
role: 'assistant',
text: 'Processing first message...',
},
],
sessionId: 'test-session',
});
}, 10);
});
// Start first request
builderStore.sendChatMessage({ text: 'first message' });
// Verify streaming is active and controller was created
expect(builderStore.streaming).toBe(true);
const firstController = builderStore.streamingAbortController;
expect(firstController).not.toBeNull();
expect(firstController).toBeInstanceOf(AbortController);
// Track if abort was called
const abortSpy = vi.spyOn(firstController!, 'abort');
// Try to send second message while streaming - it should be ignored
builderStore.sendChatMessage({ text: 'second message ignored' });
// Verify the abort was NOT called and controller is the same
expect(abortSpy).not.toHaveBeenCalled();
expect(builderStore.streamingAbortController).toBe(firstController);
// Now properly stop streaming first
builderStore.stopStreaming();
// Verify abort was called and controller was cleared
expect(abortSpy).toHaveBeenCalled();
expect(builderStore.streamingAbortController).toBeNull();
expect(builderStore.streaming).toBe(false);
// Mock for second request
apiSpy.mockImplementationOnce(() => {});
// Now we can send a new message
builderStore.sendChatMessage({ text: 'second message' });
// New controller should be created
const secondController = builderStore.streamingAbortController;
expect(secondController).not.toBe(firstController);
expect(secondController).not.toBeNull();
expect(secondController).toBeInstanceOf(AbortController);
});
it('should pass abort signal to API call', () => {
const builderStore = useBuilderStore();
// Mock the API to prevent actual network calls
apiSpy.mockImplementationOnce(() => {});
builderStore.sendChatMessage({ text: 'test' });
// Verify the API was called with correct parameters
expect(apiSpy).toHaveBeenCalled();
const callArgs = apiSpy.mock.calls[0];
expect(callArgs).toHaveLength(6); // Should have 6 arguments
const signal = callArgs[5]; // The 6th argument is the abort signal
expect(signal).toBeDefined();
expect(signal).toBeInstanceOf(AbortSignal);
// Check that it's the same signal from the controller
const controller = builderStore.streamingAbortController;
expect(controller).not.toBeNull();
expect(controller).toBeInstanceOf(AbortController);
expect(signal).toBe(controller!.signal);
});
it('should not create error message for aborted requests', async () => {
const builderStore = useBuilderStore();
// Track telemetry calls
const telemetryTrackSpy = vi.fn();
track.mockImplementation(telemetryTrackSpy);
// Simulate abort error
const abortError = new Error('AbortError');
abortError.name = 'AbortError';
apiSpy.mockImplementationOnce((_ctx, _payload, _onMessage, _onDone, onError) => {
// Call error handler immediately
onError(abortError);
});
// Clear messages before test
builderStore.chatMessages.length = 0;
builderStore.sendChatMessage({ text: 'test' });
// Wait for the error to be processed
await vi.waitFor(() => expect(builderStore.chatMessages.length).toBeGreaterThan(1));
// Should not track error for abort
expect(telemetryTrackSpy).not.toHaveBeenCalledWith(
'Workflow generation errored',
expect.anything(),
);
// Find the assistant messages (skip user message)
const assistantMessages = builderStore.chatMessages.filter((msg) => msg.role === 'assistant');
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].type).toBe('text');
expect((assistantMessages[0] as ChatUI.TextMessage).content).toBe('[Task aborted]');
});
});
describe('Rating logic integration', () => {
it('should clear ratings from existing messages when preparing for streaming', () => {
const builderStore = useBuilderStore();
// Setup initial messages with ratings
builderStore.chatMessages = [
{
id: 'msg-1',
role: 'assistant',
type: 'text',
content: 'Previous message',
showRating: true,
ratingStyle: 'regular',
read: false,
} satisfies ChatUI.AssistantMessage,
{
id: 'msg-2',
role: 'assistant',
type: 'text',
content: 'Another message',
showRating: true,
ratingStyle: 'minimal',
read: false,
} satisfies ChatUI.AssistantMessage,
];
// Mock API to prevent actual network calls
apiSpy.mockImplementationOnce(() => {});
// Send new message which calls prepareForStreaming
builderStore.sendChatMessage({ text: 'New message' });
// Verify that existing messages no longer have rating properties
expect(builderStore.chatMessages).toHaveLength(3); // 2 existing + 1 new user message
const firstMessage = builderStore.chatMessages[0] as ChatUI.TextMessage;
expect(firstMessage).not.toHaveProperty('showRating');
expect(firstMessage).not.toHaveProperty('ratingStyle');
expect(firstMessage.content).toBe('Previous message');
const secondMessage = builderStore.chatMessages[1] as ChatUI.TextMessage;
expect(secondMessage).not.toHaveProperty('showRating');
expect(secondMessage).not.toHaveProperty('ratingStyle');
expect(secondMessage.content).toBe('Another message');
// New user message should not have rating properties
const userMessage = builderStore.chatMessages[2] as ChatUI.TextMessage;
expect(userMessage.role).toBe('user');
expect(userMessage.content).toBe('New message');
expect(userMessage).not.toHaveProperty('showRating');
expect(userMessage).not.toHaveProperty('ratingStyle');
});
});
});