fix(editor): Show error toasts in Instance AI executable canvas (#29328)

This commit is contained in:
Albert Alises 2026-04-27 16:57:06 +02:00 committed by GitHub
parent 0a89814220
commit dc33223d3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 373 additions and 71 deletions

View File

@ -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<string, unknown>) => {
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',
});
});
});

View File

@ -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,
}),
'*',

View File

@ -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<string, unknown>) {
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', () => {

View File

@ -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);

View File

@ -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('<p>Error should appear</p>');
});
});
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', () => {

View File

@ -21,7 +21,9 @@ export function useToast() {
const { APP_Z_INDEXES } = useStyles();
function showMessage(messageData: Partial<NotificationOptions>, track = true) {
if (uiStore.areNotificationsSuppressed) {
const suppressed = uiStore.areNotificationsSuppressed;
const allowErrors = uiStore.allowErrorNotificationsWhenSuppressed;
if (suppressed && !(allowErrors && messageData.type === 'error')) {
return { close: () => {} } as NotificationHandle;
}

View File

@ -299,6 +299,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
const addFirstStepOnLoad = ref<boolean>(false);
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
const areNotificationsSuppressed = ref(false);
const allowErrorNotificationsWhenSuppressed = ref(false);
const processingExecutionResults = ref<boolean>(false);
const isBlankRedirect = ref<boolean>(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,

View File

@ -16,8 +16,19 @@ const renderComponent = createComponentRenderer(InstanceAiWorkflowPreview, {
global: {
stubs: {
WorkflowPreview: {
template: '<div data-test-id="workflow-preview" />',
props: ['mode', 'workflow', 'executionId', 'canOpenNdv', 'hideControls', 'loaderType'],
template:
'<div data-test-id="workflow-preview" :data-can-execute="canExecute" :data-suppress-notifications="suppressNotifications" :data-allow-error-notifications="allowErrorNotifications" />',
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 },

View File

@ -193,6 +193,7 @@ defineExpose({ relayPushEvent });
:can-execute="true"
:hide-controls="false"
:suppress-notifications="true"
:allow-error-notifications="true"
loader-type="spinner"
/>