feat: Add workflow name generation and save after initial generation (no-changelog) (#18348)

This commit is contained in:
Eugene
2025-08-15 15:38:48 +02:00
committed by GitHub
parent abf7b11e09
commit 1ddb10c3c8
13 changed files with 1101 additions and 24 deletions

View File

@@ -1,4 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
// Mock workflow saving first before any other imports
const saveCurrentWorkflowMock = vi.hoisted(() => vi.fn());
vi.mock('@/composables/useWorkflowSaving', () => ({
useWorkflowSaving: vi.fn().mockReturnValue({
saveCurrentWorkflow: saveCurrentWorkflowMock,
getWorkflowDataToSave: vi.fn(),
setDocumentTitle: vi.fn(),
executeData: vi.fn(),
getNodeTypes: vi.fn().mockReturnValue([]),
}),
}));
import { createComponentRenderer } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
@@ -12,6 +25,7 @@ import { useBuilderStore } from '@/stores/builder.store';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeUi } from '@/Interface';
vi.mock('@/event-bus', () => ({
nodeViewEventBus: {
@@ -53,16 +67,6 @@ vi.mock('vue-router', () => {
};
});
vi.mock('@/composables/useWorkflowSaving', () => ({
useWorkflowSaving: vi.fn().mockReturnValue({
saveCurrentWorkflow: vi.fn(),
getWorkflowDataToSave: vi.fn(),
setDocumentTitle: vi.fn(),
executeData: vi.fn(),
getNodeTypes: vi.fn().mockReturnValue([]),
}),
}));
const workflowPrompt = 'Create a workflow';
describe('AskAssistantBuild', () => {
const sessionId = faker.string.uuid();
@@ -140,7 +144,10 @@ describe('AskAssistantBuild', () => {
await flushPromises();
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({ text: testMessage });
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
text: testMessage,
});
});
});
@@ -313,4 +320,660 @@ describe('AskAssistantBuild', () => {
});
});
});
describe('workflow saving after generation', () => {
it('should save workflow after initial generation when workflow was empty', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
// Send initial message to start generation
const testMessage = 'Create a workflow to send emails';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
text: testMessage,
});
// Simulate streaming starts
builderStore.$patch({ streaming: true, initialGeneration: true });
await flushPromises();
// Simulate workflow update with nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Add successful message to chat to indicate successful generation
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: testMessage },
{ id: '2', role: 'assistant', type: 'text', content: 'Workflow created successfully' },
],
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was saved
expect(saveCurrentWorkflowMock).toHaveBeenCalled();
});
it('should NOT save workflow after generation when workflow already had nodes', async () => {
// Setup: workflow with existing nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'existing',
name: 'Existing',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
// Send message to modify existing workflow
const testMessage = 'Add an HTTP node';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: false,
text: testMessage,
});
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Simulate workflow update with additional nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'existing',
name: 'Existing',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
{
id: 'node2',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was NOT saved
expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(0);
});
it('should NOT save workflow when generation ends with error', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
// Send initial message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
// The component should have set initialGeneration to true since workflow was empty
await flushPromises();
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Simulate workflow update with nodes BEFORE error occurs
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Simulate error message added to chat
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: testMessage },
{ id: '2', role: 'assistant', type: 'error', content: 'An error occurred' },
],
});
// Simulate streaming ends with error
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was NOT saved despite having nodes because of the error
expect(saveCurrentWorkflowMock).not.toHaveBeenCalled();
});
it('should NOT save workflow when generation is cancelled', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
// Send initial message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Simulate workflow update with nodes BEFORE cancellation
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// User cancels generation - this adds a "[Task aborted]" message
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: testMessage },
{ id: '2', role: 'assistant', type: 'text', content: '[Task aborted]' },
],
});
// Simulate streaming ends after cancellation
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was NOT saved despite having nodes because generation was cancelled
expect(saveCurrentWorkflowMock).not.toHaveBeenCalled();
});
it('should save new workflow before sending first message', async () => {
// Setup: new workflow
workflowsStore.isNewWorkflow = true;
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
const { findByTestId } = renderComponent();
// Send initial message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
await flushPromises();
// Verify workflow was saved to get ID for session
expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(1);
});
it('should work when opening existing AI builder session', async () => {
// Setup: existing workflow with AI session
workflowsStore.workflowId = 'existing-id';
workflowsStore.isNewWorkflow = false;
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
// Simulate existing AI session messages
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'Previous message' },
{ id: '2', role: 'assistant', type: 'text', content: 'Previous response' },
],
});
const { findByTestId } = renderComponent();
// Send new message in existing session
const testMessage = 'Add email nodes';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
// Simulate streaming starts
builderStore.$patch({ streaming: true, initialGeneration: true });
await flushPromises();
// Add nodes to workflow
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Email',
type: 'n8n-nodes-base.emailSend',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Add successful message to chat (building on existing session messages)
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'Previous message' },
{ id: '2', role: 'assistant', type: 'text', content: 'Previous response' },
{ id: '3', role: 'user', type: 'text', content: testMessage },
{ id: '4', role: 'assistant', type: 'text', content: 'Added email nodes successfully' },
],
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was saved
expect(saveCurrentWorkflowMock).toHaveBeenCalled();
});
it('should save workflow when user deletes all nodes then regenerates', async () => {
// Setup: workflow with existing nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'existing',
name: 'Existing',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
// User manually deletes all nodes (simulated by clearing workflow)
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
await flushPromises();
// Send message to generate new workflow
const testMessage = 'Create a new workflow';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
expect(builderStore.sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
text: testMessage,
});
// Simulate streaming starts
builderStore.$patch({ streaming: true, initialGeneration: true });
await flushPromises();
// Add new nodes to workflow
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'new-node',
name: 'New Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Add successful message to chat
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: testMessage },
{
id: '2',
role: 'assistant',
type: 'text',
content: 'New workflow created successfully',
},
],
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was saved after regeneration
expect(saveCurrentWorkflowMock).toHaveBeenCalled();
});
it('should NOT save if workflow is still empty after generation ends', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
const { findByTestId } = renderComponent();
// Send message
const testMessage = 'Create a workflow';
const chatInput = await findByTestId('chat-input');
await fireEvent.update(chatInput, testMessage);
const sendButton = await findByTestId('send-message-button');
await fireEvent.click(sendButton);
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Workflow remains empty (maybe AI couldn't generate anything)
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was NOT saved
expect(saveCurrentWorkflowMock).not.toHaveBeenCalled();
});
});
describe('canvas-initiated generation', () => {
it('should save workflow after initial generation from canvas', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
renderComponent();
// Simulate canvas-initiated generation with initialGeneration flag
builderStore.initialGeneration = true;
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Simulate workflow update with nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Add successful message to chat
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' },
{ id: '2', role: 'assistant', type: 'text', content: 'Workflow created successfully' },
],
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was saved
expect(saveCurrentWorkflowMock).toHaveBeenCalled();
// Verify initialGeneration flag was reset
expect(builderStore.initialGeneration).toBe(false);
});
it('should NOT save workflow from canvas when generation fails', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
renderComponent();
// Simulate canvas-initiated generation with initialGeneration flag
builderStore.initialGeneration = true;
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Simulate workflow update with nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Add error message to chat
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' },
{ id: '2', role: 'assistant', type: 'error', content: 'Generation failed' },
],
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was NOT saved
expect(saveCurrentWorkflowMock).not.toHaveBeenCalled();
// Verify initialGeneration flag was reset
expect(builderStore.initialGeneration).toBe(false);
});
it('should NOT save workflow from canvas when generation is cancelled', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
renderComponent();
// Simulate canvas-initiated generation with initialGeneration flag
builderStore.initialGeneration = true;
// Simulate streaming starts
builderStore.$patch({ streaming: true });
await flushPromises();
// Simulate workflow update with nodes
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
// Add cancellation message to chat
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'Create workflow from canvas' },
{ id: '2', role: 'assistant', type: 'text', content: '[Task aborted]' },
],
});
// Simulate streaming ends
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify workflow was NOT saved
expect(saveCurrentWorkflowMock).not.toHaveBeenCalled();
// Verify initialGeneration flag was reset
expect(builderStore.initialGeneration).toBe(false);
});
it('should handle multiple canvas generations correctly', async () => {
// Setup: empty workflow
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
workflowsStore.isNewWorkflow = false;
renderComponent();
// First canvas generation
builderStore.initialGeneration = true;
builderStore.$patch({ streaming: true });
await flushPromises();
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'First generation' },
{ id: '2', role: 'assistant', type: 'text', content: 'Success' },
],
});
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify first generation saved
expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(1);
expect(builderStore.initialGeneration).toBe(false);
// User clears workflow manually
workflowsStore.$patch({ workflow: { nodes: [], connections: {} } });
// Second canvas generation
builderStore.initialGeneration = true;
builderStore.$patch({ streaming: true });
await flushPromises();
workflowsStore.$patch({
workflow: {
nodes: [
{
id: 'node2',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
},
});
builderStore.$patch({
chatMessages: [
{ id: '1', role: 'user', type: 'text', content: 'First generation' },
{ id: '2', role: 'assistant', type: 'text', content: 'Success' },
{ id: '3', role: 'user', type: 'text', content: 'Second generation' },
{ id: '4', role: 'assistant', type: 'text', content: 'Success again' },
],
});
builderStore.$patch({ streaming: false });
await flushPromises();
// Verify second generation also saved
expect(saveCurrentWorkflowMock).toHaveBeenCalledTimes(2);
expect(builderStore.initialGeneration).toBe(false);
});
});
});

View File

@@ -44,7 +44,11 @@ async function onUserMessage(content: string) {
if (isNewWorkflow) {
await workflowSaver.saveCurrentWorkflow();
}
builderStore.sendChatMessage({ text: content });
// If the workflow is empty, set the initial generation flag
const isInitialGeneration = workflowsStore.workflow.nodes.length === 0;
builderStore.sendChatMessage({ text: content, initialGeneration: isInitialGeneration });
}
// Watch for workflow updates and apply them
@@ -70,6 +74,7 @@ watch(
nodesIdsToTidyUp: result.newNodeIds,
regenerateIds: false,
});
// Track tool usage for telemetry
const newToolMessages = builderStore.toolMessages.filter(
(toolMsg) =>
@@ -94,6 +99,33 @@ watch(
{ deep: true },
);
// If this is the initial generation, streaming has ended, and there were workflow updates,
// we want to save the workflow
watch(
() => builderStore.streaming,
async () => {
if (
builderStore.initialGeneration &&
!builderStore.streaming &&
workflowsStore.workflow.nodes.length > 0
) {
// Check if the generation completed successfully (no error or cancellation)
const lastMessage = builderStore.chatMessages[builderStore.chatMessages.length - 1];
const successful =
lastMessage &&
lastMessage.type !== 'error' &&
!(lastMessage.type === 'text' && lastMessage.content === '[Task aborted]');
builderStore.initialGeneration = false;
// Only save if generation completed successfully
if (successful) {
await workflowSaver.saveCurrentWorkflow();
}
}
},
);
function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();

View File

@@ -129,6 +129,7 @@ describe('CanvasNodeAIPrompt', () => {
await waitFor(() => {
expect(openChat).toHaveBeenCalled();
expect(sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
text: 'Test prompt',
source: 'canvas',
});
@@ -174,6 +175,7 @@ describe('CanvasNodeAIPrompt', () => {
await waitFor(() => {
expect(openChat).toHaveBeenCalled();
expect(sendChatMessage).toHaveBeenCalledWith({
initialGeneration: true,
text: 'Test workflow prompt',
source: 'canvas',
});

View File

@@ -55,7 +55,8 @@ async function onSubmit() {
// Here we need to await for chat to open and session to be loaded
await builderStore.openChat();
isLoading.value = false;
builderStore.sendChatMessage({ text: prompt.value, source: 'canvas' });
// Always pass initialGeneration as true from canvas since the prompt only shows on empty canvas
builderStore.sendChatMessage({ text: prompt.value, source: 'canvas', initialGeneration: true });
}
/**

View File

@@ -9,7 +9,7 @@ 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 { WORKFLOW_BUILDER_EXPERIMENT, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
import { reactive } from 'vue';
import * as chatAPI from '@/api/ai';
import * as telemetryModule from '@/composables/useTelemetry';
@@ -24,6 +24,36 @@ vi.mock('@n8n/i18n', () => ({
}),
}));
// Mock useToast
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
showMessage: vi.fn(),
}),
}));
// Mock the workflows store
const mockSetWorkflowName = vi.fn();
const mockRemoveAllConnections = vi.fn();
const mockRemoveAllNodes = vi.fn();
const mockWorkflow = {
name: DEFAULT_NEW_WORKFLOW_NAME,
nodes: [],
connections: {},
};
vi.mock('./workflows.store', () => ({
useWorkflowsStore: vi.fn(() => ({
workflow: mockWorkflow,
workflowId: 'test-workflow-id',
allNodes: [],
nodesByName: {},
workflowExecutionData: null,
setWorkflowName: mockSetWorkflowName,
removeAllConnections: mockRemoveAllConnections,
removeAllNodes: mockRemoveAllNodes,
})),
}));
let settingsStore: ReturnType<typeof useSettingsStore>;
let posthogStore: ReturnType<typeof usePostHog>;
@@ -76,6 +106,14 @@ describe('AI Builder store', () => {
posthogStore = usePostHog();
posthogStore.init();
track.mockReset();
// Reset workflow store mocks
mockSetWorkflowName.mockReset();
mockRemoveAllConnections.mockReset();
mockRemoveAllNodes.mockReset();
// Reset workflow to default state
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
mockWorkflow.nodes = [];
mockWorkflow.connections = {};
});
afterEach(() => {
@@ -778,4 +816,240 @@ describe('AI Builder store', () => {
expect(userMessage).not.toHaveProperty('ratingStyle');
});
});
describe('applyWorkflowUpdate with workflow naming', () => {
it('should apply generated workflow name during initial generation when workflow has default name', () => {
const builderStore = useBuilderStore();
// Set initial generation flag
builderStore.initialGeneration = true;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
name: 'Generated Workflow Name for Email Processing',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [250, 300],
parameters: {},
},
],
connections: {},
});
// Apply the workflow update
const result = builderStore.applyWorkflowUpdate(workflowJson);
// Verify the update was successful
expect(result.success).toBe(true);
// Verify setWorkflowName was called with the generated name
expect(mockSetWorkflowName).toHaveBeenCalledWith({
newName: 'Generated Workflow Name for Email Processing',
setStateDirty: false,
});
});
it('should NOT apply generated workflow name during initial generation when workflow has custom name', () => {
const builderStore = useBuilderStore();
// Set initial generation flag
builderStore.initialGeneration = true;
// Set a custom workflow name (not the default)
mockWorkflow.name = 'My Custom Workflow';
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
name: 'Generated Workflow Name for Email Processing',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [250, 300],
parameters: {},
},
],
connections: {},
});
// Apply the workflow update
const result = builderStore.applyWorkflowUpdate(workflowJson);
// Verify the update was successful
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
expect(mockSetWorkflowName).not.toHaveBeenCalled();
});
it('should NOT apply generated workflow name when not initial generation', () => {
const builderStore = useBuilderStore();
// Ensure initial generation flag is false
builderStore.initialGeneration = false;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
name: 'Generated Workflow Name for Email Processing',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [250, 300],
parameters: {},
},
],
connections: {},
});
// Apply the workflow update
const result = builderStore.applyWorkflowUpdate(workflowJson);
// Verify the update was successful
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
expect(mockSetWorkflowName).not.toHaveBeenCalled();
});
it('should handle workflow updates without name property', () => {
const builderStore = useBuilderStore();
// Set initial generation flag
builderStore.initialGeneration = true;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON without a name property
const workflowJson = JSON.stringify({
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [250, 300],
parameters: {},
},
],
connections: {},
});
// Apply the workflow update
const result = builderStore.applyWorkflowUpdate(workflowJson);
// Verify the update was successful
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
expect(mockSetWorkflowName).not.toHaveBeenCalled();
});
it('should handle workflow names that start with but are not exactly the default name', () => {
const builderStore = useBuilderStore();
// Set initial generation flag
builderStore.initialGeneration = true;
// Set workflow name that starts with default but has more text
mockWorkflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
name: 'Generated Workflow Name for Email Processing',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [250, 300],
parameters: {},
},
],
connections: {},
});
// Apply the workflow update
const result = builderStore.applyWorkflowUpdate(workflowJson);
// Verify the update was successful
expect(result.success).toBe(true);
// Verify setWorkflowName WAS called because the name starts with default
expect(mockSetWorkflowName).toHaveBeenCalledWith({
newName: 'Generated Workflow Name for Email Processing',
setStateDirty: false,
});
});
it('should handle malformed JSON gracefully', () => {
const builderStore = useBuilderStore();
// Set initial generation flag
builderStore.initialGeneration = true;
// Create malformed JSON
const workflowJson = '{ invalid json }';
// Apply the workflow update
const result = builderStore.applyWorkflowUpdate(workflowJson);
// Verify the update failed
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should maintain initial generation flag state across multiple updates', () => {
const builderStore = useBuilderStore();
// Set initial generation flag
builderStore.initialGeneration = true;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// First update with name
const workflowJson1 = JSON.stringify({
name: 'First Generated Name',
nodes: [],
connections: {},
});
builderStore.applyWorkflowUpdate(workflowJson1);
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
// The flag should still be true for subsequent updates in the same generation
expect(builderStore.initialGeneration).toBe(true);
// Second update without name (simulating further tool operations)
const workflowJson2 = JSON.stringify({
nodes: [
{
id: 'node2',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [450, 300],
parameters: {},
},
],
connections: {},
});
builderStore.applyWorkflowUpdate(workflowJson2);
// Should not call setWorkflowName again
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,5 +1,6 @@
import type { VIEWS } from '@/constants';
import {
DEFAULT_NEW_WORKFLOW_NAME,
ASK_AI_SLIDE_OUT_DURATION_MS,
EDITABLE_CANVAS_VIEWS,
WORKFLOW_BUILDER_EXPERIMENT,
@@ -37,6 +38,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const streaming = ref<boolean>(false);
const assistantThinkingMessage = ref<string | undefined>();
const streamingAbortController = ref<AbortController | null>(null);
const initialGeneration = ref<boolean>(false);
// Store dependencies
const settings = useSettingsStore();
@@ -101,6 +103,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
function resetBuilderChat() {
chatMessages.value = clearMessages();
assistantThinkingMessage.value = undefined;
initialGeneration.value = false;
}
/**
@@ -234,12 +237,18 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
text: string;
source?: 'chat' | 'canvas';
quickReplyType?: string;
initialGeneration?: boolean;
}) {
if (streaming.value) {
return;
}
const { text, source = 'chat', quickReplyType } = options;
// Set initial generation flag if provided
if (options.initialGeneration !== undefined) {
initialGeneration.value = options.initialGeneration;
}
const messageId = generateMessageId();
const currentWorkflowJson = getWorkflowSnapshot();
@@ -370,12 +379,22 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
}
// Capture current state before clearing
const { nodePositions } = captureCurrentWorkflowState();
const { nodePositions, existingNodeIds } = captureCurrentWorkflowState();
// Clear existing workflow
workflowsStore.removeAllConnections({ setStateDirty: false });
workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
// For the initial generation, we want to apply auto-generated workflow name
// but only if the workflow has default name
if (
workflowData.name &&
initialGeneration.value &&
workflowsStore.workflow.name.startsWith(DEFAULT_NEW_WORKFLOW_NAME)
) {
workflowsStore.setWorkflowName({ newName: workflowData.name, setStateDirty: false });
}
// Restore positions for nodes that still exist and identify new nodes
const nodesIdsToTidyUp: string[] = [];
if (workflowData.nodes) {
@@ -391,7 +410,12 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
});
}
return { success: true, workflowData, newNodeIds: nodesIdsToTidyUp };
return {
success: true,
workflowData,
newNodeIds: nodesIdsToTidyUp,
oldNodeIds: Array.from(existingNodeIds),
};
}
function getWorkflowSnapshot() {
@@ -416,6 +440,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
workflowMessages,
trackingSessionId,
streamingAbortController,
initialGeneration,
// Methods
updateWindowWidth,