diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowPreview.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowPreview.test.ts index 9539f4e7ea4..1d624ee2df4 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowPreview.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowPreview.test.ts @@ -1,7 +1,7 @@ import type { Mock, MockInstance } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import { waitFor } from '@testing-library/vue'; -import type { ExecutionSummary } from 'n8n-workflow'; +import { jsonParse, type ExecutionSummary } from 'n8n-workflow'; import { createComponentRenderer } from '@/__tests__/render'; import type { INodeUi, IWorkflowDb } from '@/Interface'; import WorkflowPreview from '@/app/components/WorkflowPreview.vue'; @@ -21,6 +21,14 @@ const sendPostMessageCommand = (command: string) => { window.postMessage(`{"command":"${command}"}`, '*'); }; +const expectIframePostMessage = (expectedPayload: Record) => { + const payloads = postMessageSpy.mock.calls + .filter(([payload, targetOrigin]) => typeof payload === 'string' && targetOrigin === '*') + .map(([payload]) => jsonParse(payload as string)); + + expect(payloads).toEqual(expect.arrayContaining([expect.objectContaining(expectedPayload)])); +}; + describe('WorkflowPreview', () => { beforeEach(() => { pinia = createPinia(); @@ -105,21 +113,45 @@ describe('WorkflowPreview', () => { sendPostMessageCommand('n8nReady'); await waitFor(() => { - expect(postMessageSpy).toHaveBeenCalledWith( - JSON.stringify({ - command: 'openWorkflow', - workflow, - canOpenNDV: true, - hideNodeIssues: false, - suppressNotifications: false, - projectId: 'test-project-id', - }), - '*', - ); + expectIframePostMessage({ + command: 'openWorkflow', + workflow, + canOpenNDV: true, + hideNodeIssues: false, + suppressNotifications: false, + allowErrorNotifications: false, + projectId: 'test-project-id', + }); expect(focusSpy).toHaveBeenCalled(); }); }); + it('should pass allowErrorNotifications using PostMessage when enabled', async () => { + const nodes = [{ name: 'Start' }] as INodeUi[]; + const workflow = { nodes } as IWorkflowDb; + renderComponent({ + pinia, + props: { + workflow, + allowErrorNotifications: true, + }, + }); + + sendPostMessageCommand('n8nReady'); + + await waitFor(() => { + expectIframePostMessage({ + command: 'openWorkflow', + workflow, + canOpenNDV: true, + hideNodeIssues: false, + suppressNotifications: false, + allowErrorNotifications: true, + projectId: 'test-project-id', + }); + }); + }); + it('should not call iframe postMessage with "openExecution" when executionId is passed but mode not set to "execution"', async () => { const executionId = '123'; renderComponent({ @@ -149,16 +181,13 @@ describe('WorkflowPreview', () => { sendPostMessageCommand('n8nReady'); await waitFor(() => { - expect(postMessageSpy).toHaveBeenCalledWith( - JSON.stringify({ - command: 'openExecution', - executionId, - executionMode: '', - canOpenNDV: true, - projectId: 'test-project-id', - }), - '*', - ); + expectIframePostMessage({ + command: 'openExecution', + executionId, + executionMode: '', + canOpenNDV: true, + projectId: 'test-project-id', + }); }); }); @@ -179,24 +208,18 @@ describe('WorkflowPreview', () => { sendPostMessageCommand('n8nReady'); await waitFor(() => { - expect(postMessageSpy).toHaveBeenCalledWith( - JSON.stringify({ - command: 'openExecution', - executionId, - executionMode: '', - canOpenNDV: true, - projectId: 'test-project-id', - }), - '*', - ); + expectIframePostMessage({ + command: 'openExecution', + executionId, + executionMode: '', + canOpenNDV: true, + projectId: 'test-project-id', + }); - expect(postMessageSpy).toHaveBeenCalledWith( - JSON.stringify({ - command: 'setActiveExecution', - executionId: 'abc', - }), - '*', - ); + expectIframePostMessage({ + command: 'setActiveExecution', + executionId: 'abc', + }); }); }); @@ -217,17 +240,15 @@ describe('WorkflowPreview', () => { sendPostMessageCommand('n8nReady'); await waitFor(() => { - expect(postMessageSpy).toHaveBeenCalledWith( - JSON.stringify({ - command: 'openWorkflow', - workflow, - canOpenNDV: true, - hideNodeIssues: false, - suppressNotifications: false, - projectId: 'test-project-id', - }), - '*', - ); + expectIframePostMessage({ + command: 'openWorkflow', + workflow, + canOpenNDV: true, + hideNodeIssues: false, + suppressNotifications: false, + allowErrorNotifications: false, + projectId: 'test-project-id', + }); }); sendPostMessageCommand('openNDV'); @@ -255,17 +276,15 @@ describe('WorkflowPreview', () => { }); sendPostMessageCommand('n8nReady'); await waitFor(() => { - expect(postMessageSpy).toHaveBeenCalledWith( - JSON.stringify({ - command: 'openWorkflow', - workflow, - canOpenNDV: false, - hideNodeIssues: false, - suppressNotifications: false, - projectId: 'test-project-id', - }), - '*', - ); + expectIframePostMessage({ + command: 'openWorkflow', + workflow, + canOpenNDV: false, + hideNodeIssues: false, + suppressNotifications: false, + allowErrorNotifications: false, + projectId: 'test-project-id', + }); }); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue b/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue index 269eda88458..78d6234b722 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowPreview.vue @@ -22,6 +22,7 @@ const props = withDefaults( focusOnLoad?: boolean; hideControls?: boolean; suppressNotifications?: boolean; + allowErrorNotifications?: boolean; canExecute?: boolean; }>(), { @@ -37,6 +38,7 @@ const props = withDefaults( focusOnLoad: true, hideControls: false, suppressNotifications: false, + allowErrorNotifications: false, canExecute: false, }, ); @@ -95,6 +97,7 @@ const loadWorkflow = () => { canOpenNDV: props.canOpenNDV, hideNodeIssues: props.hideNodeIssues, suppressNotifications: props.suppressNotifications, + allowErrorNotifications: props.allowErrorNotifications, projectId: projectsStore.currentProjectId, }), '*', diff --git a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts index 307be475245..417ee0149d9 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts @@ -5,6 +5,7 @@ import { createTestingPinia } from '@pinia/testing'; import { jsonParse } from 'n8n-workflow'; import { usePostMessageHandler } from './usePostMessageHandler'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import { useUIStore } from '@/app/stores/ui.store'; import type { WorkflowState } from '@/app/composables/useWorkflowState'; import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; @@ -47,10 +48,12 @@ vi.mock('@/app/composables/useTelemetry', () => ({ })), })); +const mockToastShowError = vi.hoisted(() => vi.fn()); +const mockToastShowMessage = vi.hoisted(() => vi.fn()); vi.mock('@/app/composables/useToast', () => ({ useToast: vi.fn(() => ({ - showError: vi.fn(), - showMessage: vi.fn(), + showError: mockToastShowError, + showMessage: mockToastShowMessage, })), })); @@ -97,6 +100,14 @@ function createMockWorkflowState(): WorkflowState { } as unknown as WorkflowState; } +function dispatchPostMessage(payload: Record) { + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify(payload), + }), + ); +} + describe('usePostMessageHandler', () => { let workflowState: WorkflowState; @@ -198,6 +209,96 @@ describe('usePostMessageHandler', () => { cleanup(); }); + it('should set notification suppression and error allowance from openWorkflow message', async () => { + setActivePinia(createTestingPinia({ stubActions: false })); + const uiStore = useUIStore(); + const { setup, cleanup } = usePostMessageHandler({ + workflowState, + currentWorkflowDocumentStore: shallowRef(null), + }); + setup(); + + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + command: 'openWorkflow', + workflow: { nodes: [], connections: {} }, + suppressNotifications: true, + allowErrorNotifications: true, + }), + }), + ); + + await vi.waitFor(() => { + expect(mockImportWorkflowExact).toHaveBeenCalled(); + }); + + expect(uiStore.areNotificationsSuppressed).toBe(true); + expect(uiStore.allowErrorNotificationsWhenSuppressed).toBe(true); + + cleanup(); + }); + + it('should clear notification suppression and error allowance when suppression is false', async () => { + setActivePinia(createTestingPinia({ stubActions: false })); + const uiStore = useUIStore(); + uiStore.setNotificationsSuppressed(true, { allowErrors: true }); + const { setup, cleanup } = usePostMessageHandler({ + workflowState, + currentWorkflowDocumentStore: shallowRef(null), + }); + setup(); + + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + command: 'openWorkflow', + workflow: { nodes: [], connections: {} }, + suppressNotifications: false, + allowErrorNotifications: true, + }), + }), + ); + + await vi.waitFor(() => { + expect(mockImportWorkflowExact).toHaveBeenCalled(); + }); + + expect(uiStore.areNotificationsSuppressed).toBe(false); + expect(uiStore.allowErrorNotificationsWhenSuppressed).toBe(false); + + cleanup(); + }); + + it('should clear notification suppression and error allowance when suppression is absent', async () => { + setActivePinia(createTestingPinia({ stubActions: false })); + const uiStore = useUIStore(); + uiStore.setNotificationsSuppressed(true, { allowErrors: true }); + const { setup, cleanup } = usePostMessageHandler({ + workflowState, + currentWorkflowDocumentStore: shallowRef(null), + }); + setup(); + + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + command: 'openWorkflow', + workflow: { nodes: [], connections: {} }, + }), + }), + ); + + await vi.waitFor(() => { + expect(mockImportWorkflowExact).toHaveBeenCalled(); + }); + + expect(uiStore.areNotificationsSuppressed).toBe(false); + expect(uiStore.allowErrorNotificationsWhenSuppressed).toBe(false); + + cleanup(); + }); + it('should override workflow id to "demo" on demo route when canExecute is not set', async () => { mockRoute.name = 'WorkflowDemo'; @@ -401,6 +502,47 @@ describe('usePostMessageHandler', () => { cleanup(); }); + it('should show an error toast when opening execution fails with error allowance enabled', async () => { + setActivePinia(createTestingPinia({ stubActions: false })); + const uiStore = useUIStore(); + const { setup, cleanup } = usePostMessageHandler({ + workflowState, + currentWorkflowDocumentStore: shallowRef(null), + }); + setup(); + + dispatchPostMessage({ + command: 'openWorkflow', + workflow: { nodes: [], connections: {} }, + suppressNotifications: true, + allowErrorNotifications: true, + }); + + await vi.waitFor(() => { + expect(mockImportWorkflowExact).toHaveBeenCalled(); + }); + + expect(uiStore.areNotificationsSuppressed).toBe(true); + expect(uiStore.allowErrorNotificationsWhenSuppressed).toBe(true); + + mockOpenExecution.mockRejectedValueOnce(new Error('Execution could not be opened')); + dispatchPostMessage({ + command: 'openExecution', + executionId: 'exec-1', + executionMode: 'trigger', + }); + + await vi.waitFor(() => { + expect(mockToastShowMessage).toHaveBeenCalledWith({ + title: expect.any(String), + message: 'Execution could not be opened', + type: 'error', + }); + }); + + cleanup(); + }); + it('should not set isProductionExecutionPreview for manual executions', async () => { mockOpenExecution.mockResolvedValue({ workflowData: { id: 'w1', name: 'Test' }, @@ -540,6 +682,47 @@ describe('usePostMessageHandler', () => { cleanup(); }); + + it('should show an error toast when opening execution preview fails with error allowance enabled', async () => { + setActivePinia(createTestingPinia({ stubActions: false })); + const uiStore = useUIStore(); + const { setup, cleanup } = usePostMessageHandler({ + workflowState, + currentWorkflowDocumentStore: shallowRef(null), + }); + setup(); + + dispatchPostMessage({ + command: 'openWorkflow', + workflow: { nodes: [], connections: {} }, + suppressNotifications: true, + allowErrorNotifications: true, + }); + + await vi.waitFor(() => { + expect(mockImportWorkflowExact).toHaveBeenCalled(); + }); + + expect(uiStore.areNotificationsSuppressed).toBe(true); + expect(uiStore.allowErrorNotificationsWhenSuppressed).toBe(true); + + dispatchPostMessage({ + command: 'openExecutionPreview', + workflow: { connections: {} }, + nodeExecutionSchema: {}, + executionStatus: 'success', + }); + + await vi.waitFor(() => { + expect(mockToastShowMessage).toHaveBeenCalledWith({ + title: expect.any(String), + message: 'Invalid workflow object', + type: 'error', + }); + }); + + cleanup(); + }); }); describe('message filtering', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts index b0f806847bf..5749eeaa4f3 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts @@ -78,10 +78,11 @@ export function usePostMessageHandler({ projectId?: string; tidyUp?: boolean; suppressNotifications?: boolean; + allowErrorNotifications?: boolean; }) { - if (json.suppressNotifications) { - uiStore.setNotificationsSuppressed(true); - } + uiStore.setNotificationsSuppressed(json.suppressNotifications === true, { + allowErrors: json.allowErrorNotifications === true, + }); if (json.projectId) { await projectsStore.fetchAndSetProject(json.projectId); diff --git a/packages/frontend/editor-ui/src/app/composables/useToast.test.ts b/packages/frontend/editor-ui/src/app/composables/useToast.test.ts index 3192aaf77c2..5bac7d98e08 100644 --- a/packages/frontend/editor-ui/src/app/composables/useToast.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useToast.test.ts @@ -215,9 +215,10 @@ describe('useToast', () => { }); describe('notification suppression', () => { - it('should not render notification when notifications are suppressed', async () => { + it('should not render non-error notification when notifications are suppressed', async () => { const uiStore = useUIStore(); uiStore.areNotificationsSuppressed = true; + uiStore.allowErrorNotificationsWhenSuppressed = true; toast.showMessage({ message: 'Should not appear', title: 'Suppressed' }); @@ -232,6 +233,69 @@ describe('useToast', () => { ), ).rejects.toThrow(); }); + + it('should not render error notification when notifications are suppressed and errors are not allowed', async () => { + const uiStore = useUIStore(); + uiStore.areNotificationsSuppressed = true; + uiStore.allowErrorNotificationsWhenSuppressed = false; + + toast.showMessage({ + message: 'Error should not appear', + title: 'Suppressed error', + type: 'error', + }); + + await expect( + waitFor( + () => { + expect(screen.getByRole('alert')).toBeVisible(); + }, + { timeout: 200 }, + ), + ).rejects.toThrow(); + expect(telemetryTrackSpy).not.toHaveBeenCalled(); + }); + + it('should render error notification when notifications are suppressed and errors are allowed', async () => { + const uiStore = useUIStore(); + uiStore.areNotificationsSuppressed = true; + uiStore.allowErrorNotificationsWhenSuppressed = true; + + toast.showMessage({ + message: 'Error should appear', + title: 'Allowed error', + type: 'error', + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeVisible(); + expect( + within(screen.getByRole('alert')).getByRole('heading', { level: 2 }), + ).toHaveTextContent('Allowed error'); + expect(screen.getByRole('alert')).toContainHTML('

Error should appear

'); + }); + }); + + it('should track telemetry for allowed suppressed error notification', async () => { + const uiStore = useUIStore(); + uiStore.areNotificationsSuppressed = true; + uiStore.allowErrorNotificationsWhenSuppressed = true; + + toast.showMessage({ + message: 'Allowed error tracked', + title: 'Allowed error', + type: 'error', + }); + + await waitFor(() => { + expect(telemetryTrackSpy).toHaveBeenCalledWith('Instance FE emitted error', { + error_title: 'Allowed error', + error_message: 'Allowed error tracked', + caused_by_credential: false, + workflow_id: expect.any(String), + }); + }); + }); }); describe('clearAllStickyNotifications', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/useToast.ts b/packages/frontend/editor-ui/src/app/composables/useToast.ts index 2245347bf1b..6d276dab94f 100644 --- a/packages/frontend/editor-ui/src/app/composables/useToast.ts +++ b/packages/frontend/editor-ui/src/app/composables/useToast.ts @@ -21,7 +21,9 @@ export function useToast() { const { APP_Z_INDEXES } = useStyles(); function showMessage(messageData: Partial, track = true) { - if (uiStore.areNotificationsSuppressed) { + const suppressed = uiStore.areNotificationsSuppressed; + const allowErrors = uiStore.allowErrorNotificationsWhenSuppressed; + if (suppressed && !(allowErrors && messageData.type === 'error')) { return { close: () => {} } as NotificationHandle; } diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.ts index c7d9848f7a9..9320d6dee0d 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.ts @@ -299,6 +299,7 @@ export const useUIStore = defineStore(STORES.UI, () => { const addFirstStepOnLoad = ref(false); const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({}); const areNotificationsSuppressed = ref(false); + const allowErrorNotificationsWhenSuppressed = ref(false); const processingExecutionResults = ref(false); const isBlankRedirect = ref(false); @@ -629,8 +630,9 @@ export const useUIStore = defineStore(STORES.UI, () => { pendingNotificationsForViews.value[view] = notifications; }; - const setNotificationsSuppressed = (suppressed: boolean) => { + const setNotificationsSuppressed = (suppressed: boolean, options?: { allowErrors?: boolean }) => { areNotificationsSuppressed.value = suppressed; + allowErrorNotificationsWhenSuppressed.value = suppressed && options?.allowErrors === true; }; function resetLastInteractedWith() { @@ -756,6 +758,7 @@ export const useUIStore = defineStore(STORES.UI, () => { isAnyModalOpen, pendingNotificationsForViews, areNotificationsSuppressed, + allowErrorNotificationsWhenSuppressed, activeModals, isProcessingExecutionResults, setTheme, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowPreview.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowPreview.test.ts index 99d321c808b..5e839fdd90c 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowPreview.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowPreview.test.ts @@ -16,8 +16,19 @@ const renderComponent = createComponentRenderer(InstanceAiWorkflowPreview, { global: { stubs: { WorkflowPreview: { - template: '
', - props: ['mode', 'workflow', 'executionId', 'canOpenNdv', 'hideControls', 'loaderType'], + template: + '
', + props: [ + 'mode', + 'workflow', + 'executionId', + 'canOpenNdv', + 'canExecute', + 'hideControls', + 'suppressNotifications', + 'allowErrorNotifications', + 'loaderType', + ], }, }, }, @@ -73,6 +84,21 @@ describe('InstanceAiWorkflowPreview', () => { }); }); + it('should allow error notifications for executable preview', async () => { + mockFetchWorkflow.mockResolvedValue(fakeWorkflow); + + const { getByTestId } = renderComponent({ + props: { workflowId: 'wf-123', executionId: null }, + }); + + await waitFor(() => { + const preview = getByTestId('workflow-preview'); + expect(preview).toHaveAttribute('data-can-execute', 'true'); + expect(preview).toHaveAttribute('data-suppress-notifications', 'true'); + expect(preview).toHaveAttribute('data-allow-error-notifications', 'true'); + }); + }); + it('should emit iframe-ready on n8nReady postMessage', async () => { const { emitted } = renderComponent({ props: { workflowId: null, executionId: null }, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue index c75624ff69d..8eb928f479f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue @@ -193,6 +193,7 @@ defineExpose({ relayPushEvent }); :can-execute="true" :hide-controls="false" :suppress-notifications="true" + :allow-error-notifications="true" loader-type="spinner" />