+
+
+
+ {{ i18n.baseText('generic.loading') }}
+
+
+
{
expect(rendered.length).toBe(300);
});
});
+
+ it('shows a loading screen instead of the list while loading', async () => {
+ const { getByTestId, getByPlaceholderText, queryByTestId, rerender } = renderModal({
+ props: { open: true, items: realisticItems, categories: ALL_CATEGORIES, loading: true },
+ pinia: createTestingPinia(),
+ });
+
+ expect(getByTestId('tools-connection-loading')).toBeInTheDocument();
+ expect(queryByTestId('tools-connection-search')).toBeNull();
+
+ await rerender({ loading: false });
+
+ expect(queryByTestId('tools-connection-loading')).toBeNull();
+ await waitFor(() => {
+ expect(document.activeElement).toBe(getByPlaceholderText('Search all tools...'));
+ });
+ });
});
diff --git a/packages/frontend/editor-ui/src/features/workflows/components/WorkflowSettings/WorkflowSettings.test.ts b/packages/frontend/editor-ui/src/features/workflows/components/WorkflowSettings/WorkflowSettings.test.ts
index 8c48c403d64..f5e854d1de7 100644
--- a/packages/frontend/editor-ui/src/features/workflows/components/WorkflowSettings/WorkflowSettings.test.ts
+++ b/packages/frontend/editor-ui/src/features/workflows/components/WorkflowSettings/WorkflowSettings.test.ts
@@ -779,6 +779,30 @@ describe('WorkflowSettingsVue', () => {
},
);
+ it.each([
+ [undefined, 'v2'],
+ ['v1', 'v2'],
+ ['v2', 'v1'],
+ ] as const)(
+ 'should hold the new engineType in the document after a change from %s to %s',
+ async (engineType, expected) => {
+ workflowDocumentStore.setSettings(engineType ? { engineType } : {});
+
+ const { getByTestId, getByRole } = createComponent({ pinia });
+ await flushPromises();
+
+ const dropdownItems = await getDropdownItems(
+ getByTestId('workflow-settings-engine-type-select'),
+ );
+ await userEvent.click(dropdownItems[expected === 'v2' ? 1 : 0]);
+ await userEvent.click(getByRole('button', { name: 'Save' }));
+ await flushPromises();
+
+ // A later save before a run sends the document settings, so they must match the saved value.
+ expect(workflowDocumentStore.getSettingsSnapshot().engineType).toBe(expected);
+ },
+ );
+
it('should not add engineType to the saved settings when the select is untouched', async () => {
const { getByRole } = createComponent({ pinia });
await flushPromises();
diff --git a/packages/nodes-base/nodes/ExecutionData/ExecutionData.node.ts b/packages/nodes-base/nodes/ExecutionData/ExecutionData.node.ts
index 5ff1e998c7f..a20ae71480e 100644
--- a/packages/nodes-base/nodes/ExecutionData/ExecutionData.node.ts
+++ b/packages/nodes-base/nodes/ExecutionData/ExecutionData.node.ts
@@ -120,8 +120,10 @@ export class ExecutionData implements INodeType {
const values = dataToSave.reduce(
(acc, { key, value }) => {
- const valueToSet = value ? value : nodeVersion >= 1.1 ? '' : value;
- acc[key] = valueToSet;
+ // Expressions can resolve to booleans or numbers. Keep falsy values
+ // such as `false` and `0`, only nullish values become empty strings.
+ // The metadata setter converts valid values and rejects objects.
+ acc[key] = nodeVersion >= 1.1 ? (value ?? '') : value;
return acc;
},
{} as { [key: string]: string },
diff --git a/packages/nodes-base/nodes/ExecutionData/test/ExecutionData.node.test.ts b/packages/nodes-base/nodes/ExecutionData/test/ExecutionData.node.test.ts
index bfd1ffbfee3..923a233429f 100644
--- a/packages/nodes-base/nodes/ExecutionData/test/ExecutionData.node.test.ts
+++ b/packages/nodes-base/nodes/ExecutionData/test/ExecutionData.node.test.ts
@@ -44,6 +44,32 @@ describe('ExecutionData Node', () => {
expect(setAllMock).toBeCalledWith({ foo: 'bar' });
expect(result).toEqual([mockInputData]);
});
+
+ it.each([1, 1.1])(
+ 'should pass boolean and numeric falsy values through (v%s)',
+ async (typeVersion) => {
+ const mockInputData: INodeExecutionData[] = [{ json: { item: 0 } }];
+ const setAllMock = vi.fn();
+ const executeFns = mock({
+ customData: mock({ setAll: setAllMock }),
+ getInputData: () => mockInputData,
+ getNode: () => mock({ typeVersion }),
+ });
+ executeFns.getNodeParameter.mockReturnValueOnce('save');
+ executeFns.getNodeParameter.mockReturnValueOnce({
+ values: [
+ { key: 'isTrue', value: true },
+ { key: 'isFalse', value: false },
+ { key: 'zero', value: 0 },
+ { key: 'count', value: 3 },
+ ],
+ });
+
+ await new ExecutionData().execute.call(executeFns);
+
+ expect(setAllMock).toBeCalledWith({ isTrue: true, isFalse: false, zero: 0, count: 3 });
+ },
+ );
});
describe('ExecutionData -> Should run the workflow', () => {
diff --git a/packages/nodes-base/nodes/Strava/StravaTrigger.node.ts b/packages/nodes-base/nodes/Strava/StravaTrigger.node.ts
index df1cf21a5dc..d4339555730 100644
--- a/packages/nodes-base/nodes/Strava/StravaTrigger.node.ts
+++ b/packages/nodes-base/nodes/Strava/StravaTrigger.node.ts
@@ -1,4 +1,3 @@
-import { randomBytes } from 'crypto';
import type {
IHookFunctions,
IWebhookFunctions,
@@ -147,7 +146,7 @@ export class StravaTrigger implements INodeType {
const body = {
callback_url: webhookUrl,
- verify_token: randomBytes(20).toString('hex'),
+ verify_token: this.getNode().id,
};
let responseData;
@@ -178,17 +177,7 @@ export class StravaTrigger implements INodeType {
);
// now there is room create a subscription with the n8n data
- const requestBody = {
- callback_url: webhookUrl,
- verify_token: randomBytes(20).toString('hex'),
- };
-
- responseData = await stravaApiRequest.call(
- this,
- 'POST',
- '/push_subscriptions',
- requestBody,
- );
+ responseData = await stravaApiRequest.call(this, 'POST', endpoint, body);
} else {
error.message = `A subscription already exists [${webhooks[0].callback_url}]. If you want to delete this subscription and create a new one with the current parameters please go to options and set delete if exist to true`;
throw error;
@@ -254,6 +243,10 @@ export class StravaTrigger implements INodeType {
if (this.getWebhookName() === 'setup') {
if (query['hub.challenge']) {
// Is a create webhook confirmation request
+ if (query['hub.verify_token'] !== this.getNode().id) {
+ return {};
+ }
+
const res = this.getResponseObject();
res.status(200).json({ 'hub.challenge': query['hub.challenge'] }).end();
return {
@@ -262,6 +255,14 @@ export class StravaTrigger implements INodeType {
}
}
+ const webhookData = this.getWorkflowStaticData('node');
+ if (
+ webhookData.webhookId === undefined ||
+ String(body.subscription_id) !== String(webhookData.webhookId)
+ ) {
+ return {};
+ }
+
if (object !== '*' && !objectType.includes(body.object_type as string)) {
return {};
}
diff --git a/packages/nodes-base/nodes/Strava/__tests__/StravaTrigger.node.test.ts b/packages/nodes-base/nodes/Strava/__tests__/StravaTrigger.node.test.ts
new file mode 100644
index 00000000000..9d284fe4b82
--- /dev/null
+++ b/packages/nodes-base/nodes/Strava/__tests__/StravaTrigger.node.test.ts
@@ -0,0 +1,174 @@
+import type * as express from 'express';
+import type { IDataObject, IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
+import { mock, mockDeep } from 'vitest-mock-extended';
+
+import * as GenericFunctions from '../GenericFunctions';
+import { StravaTrigger } from '../StravaTrigger.node';
+
+describe('StravaTrigger', () => {
+ const node = new StravaTrigger();
+ const nodeId = 'test-node-id';
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('checkExists', () => {
+ it('should store the id of an existing subscription for the webhook URL', async () => {
+ const hookFunctions = mockDeep();
+ const staticData: IDataObject = {};
+ hookFunctions.getNodeWebhookUrl.mockReturnValue('https://n8n.example/webhook');
+ hookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
+ vi.spyOn(GenericFunctions, 'stravaApiRequest').mockResolvedValue([
+ { id: 123, callback_url: 'https://n8n.example/webhook' },
+ ]);
+
+ const exists = await node.webhookMethods.default.checkExists.call(hookFunctions);
+
+ expect(exists).toBe(true);
+ expect(staticData.webhookId).toBe(123);
+ });
+ });
+
+ describe('create', () => {
+ it('should register the subscription with the node id as verify token', async () => {
+ const hookFunctions = mockDeep();
+ hookFunctions.getNode.mockReturnValue(mock({ id: nodeId }));
+ hookFunctions.getNodeWebhookUrl.mockReturnValue('https://n8n.example/webhook');
+ hookFunctions.getWorkflowStaticData.mockReturnValue({});
+ const request = vi.spyOn(GenericFunctions, 'stravaApiRequest').mockResolvedValue({ id: 123 });
+
+ await node.webhookMethods.default.create.call(hookFunctions);
+
+ expect(request).toHaveBeenCalledWith('POST', '/push_subscriptions', {
+ callback_url: 'https://n8n.example/webhook',
+ verify_token: nodeId,
+ });
+ });
+ });
+
+ describe('create with an existing subscription', () => {
+ it('should replace it with the node id as verify token when deleteIfExist is set', async () => {
+ const hookFunctions = mockDeep();
+ hookFunctions.getNode.mockReturnValue(mock({ id: nodeId }));
+ hookFunctions.getNodeWebhookUrl.mockReturnValue('https://n8n.example/webhook');
+ hookFunctions.getWorkflowStaticData.mockReturnValue({});
+ hookFunctions.getNodeParameter.mockReturnValue({ deleteIfExist: true });
+ const request = vi
+ .spyOn(GenericFunctions, 'stravaApiRequest')
+ .mockRejectedValueOnce({
+ cause: { error: { errors: [{ resource: 'PushSubscription', code: 'already exists' }] } },
+ })
+ .mockResolvedValueOnce([{ id: 1, callback_url: 'https://other.example/webhook' }])
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({ id: 123 });
+
+ const created = await node.webhookMethods.default.create.call(hookFunctions);
+
+ expect(created).toBe(true);
+ expect(request).toHaveBeenCalledWith('DELETE', '/push_subscriptions/1');
+ expect(request).toHaveBeenLastCalledWith('POST', '/push_subscriptions', {
+ callback_url: 'https://n8n.example/webhook',
+ verify_token: nodeId,
+ });
+ });
+ });
+
+ describe('webhook', () => {
+ const setup = ({
+ webhookName = 'default',
+ query = {},
+ body = {},
+ }: {
+ webhookName?: string;
+ query?: Record;
+ body?: IDataObject;
+ }) => {
+ const webhookFunctions = mockDeep();
+ const res = mock();
+ res.status.mockReturnValue(res);
+ res.json.mockReturnValue(res);
+ webhookFunctions.getNode.mockReturnValue(mock({ id: nodeId }));
+ webhookFunctions.getWebhookName.mockReturnValue(webhookName);
+ webhookFunctions.getQueryData.mockReturnValue(query);
+ webhookFunctions.getBodyData.mockReturnValue(body);
+ webhookFunctions.getResponseObject.mockReturnValue(res);
+ webhookFunctions.getWorkflowStaticData.mockReturnValue({ webhookId: 123 });
+ webhookFunctions.getNodeParameter.mockImplementation((name: string) =>
+ name === 'resolveData' ? false : '*',
+ );
+ webhookFunctions.helpers.returnJsonArray.mockImplementation((data) => [
+ { json: data as never },
+ ]);
+ return { webhookFunctions, res };
+ };
+
+ it('should echo the challenge when the verify token matches', async () => {
+ const { webhookFunctions, res } = setup({
+ webhookName: 'setup',
+ query: { 'hub.challenge': 'abc', 'hub.verify_token': nodeId },
+ });
+
+ const result = await node.webhook.call(webhookFunctions);
+
+ expect(res.json).toHaveBeenCalledWith({ 'hub.challenge': 'abc' });
+ expect(result).toEqual({ noWebhookResponse: true });
+ });
+
+ it('should not echo the challenge when the verify token does not match', async () => {
+ const { webhookFunctions, res } = setup({
+ webhookName: 'setup',
+ query: { 'hub.challenge': 'abc', 'hub.verify_token': 'other' },
+ });
+
+ const result = await node.webhook.call(webhookFunctions);
+
+ expect(res.json).not.toHaveBeenCalled();
+ expect(result).toEqual({});
+ });
+
+ it.each([123, '123'])(
+ 'should start the workflow for events of the registered subscription %j',
+ async (webhookId) => {
+ const body = { object_type: 'activity', aspect_type: 'create', subscription_id: 123 };
+ const { webhookFunctions } = setup({ body });
+ webhookFunctions.getWorkflowStaticData.mockReturnValue({ webhookId });
+
+ const result = await node.webhook.call(webhookFunctions);
+
+ expect(result).toEqual({ workflowData: [[{ json: body }]] });
+ },
+ );
+
+ it('should ignore events of a different subscription', async () => {
+ const { webhookFunctions } = setup({
+ body: { object_type: 'activity', aspect_type: 'create', subscription_id: 999 },
+ });
+
+ const result = await node.webhook.call(webhookFunctions);
+
+ expect(result).toEqual({});
+ });
+
+ it('should ignore events when no subscription id is stored', async () => {
+ const { webhookFunctions } = setup({
+ body: { object_type: 'activity', aspect_type: 'create', subscription_id: 'undefined' },
+ });
+ webhookFunctions.getWorkflowStaticData.mockReturnValue({});
+
+ const result = await node.webhook.call(webhookFunctions);
+
+ expect(result).toEqual({});
+ });
+
+ it('should ignore events without a subscription id', async () => {
+ const { webhookFunctions } = setup({
+ body: { object_type: 'activity', aspect_type: 'create' },
+ });
+
+ const result = await node.webhook.call(webhookFunctions);
+
+ expect(result).toEqual({});
+ });
+ });
+});
diff --git a/packages/quality/testing/playwright/pages/WorkflowSettingsModal.ts b/packages/quality/testing/playwright/pages/WorkflowSettingsModal.ts
index e4d41ac4868..1e2992ad30c 100644
--- a/packages/quality/testing/playwright/pages/WorkflowSettingsModal.ts
+++ b/packages/quality/testing/playwright/pages/WorkflowSettingsModal.ts
@@ -51,6 +51,15 @@ export class WorkflowSettingsModal extends BasePage {
return this.container.getByTestId('workflow-settings-timeout-form').locator('input').first();
}
+ getEngineTypeSelect(): Locator {
+ return this.container.getByTestId('workflow-settings-engine-type-select');
+ }
+
+ async selectEngineType(label: string): Promise {
+ await this.getEngineTypeSelect().click();
+ await this.getVisiblePopoverOption(label, { exact: true }).click();
+ }
+
getRedactProductionSelect(): Locator {
return this.container.getByTestId('workflow-settings-redact-production-select');
}
diff --git a/packages/quality/testing/playwright/pages/components/LogsPanel.ts b/packages/quality/testing/playwright/pages/components/LogsPanel.ts
index a42709a9a1d..1443359c8d6 100644
--- a/packages/quality/testing/playwright/pages/components/LogsPanel.ts
+++ b/packages/quality/testing/playwright/pages/components/LogsPanel.ts
@@ -47,6 +47,10 @@ export class LogsPanel {
return this.root.getByTestId('logs-overview-body').getByRole('treeitem');
}
+ getNodeName(name: string): Locator {
+ return this.getLogEntries().getByText(name, { exact: true });
+ }
+
getSelectedLogEntry(): Locator {
return this.root.getByTestId('logs-overview-body').getByRole('treeitem', { selected: true });
}
diff --git a/packages/quality/testing/playwright/tests/e2e/workflows/editor/execution/engine-type-setting.spec.ts b/packages/quality/testing/playwright/tests/e2e/workflows/editor/execution/engine-type-setting.spec.ts
new file mode 100644
index 00000000000..ec08987b490
--- /dev/null
+++ b/packages/quality/testing/playwright/tests/e2e/workflows/editor/execution/engine-type-setting.spec.ts
@@ -0,0 +1,77 @@
+import { UUID_V7_PATTERN } from '@n8n/constants';
+import { nanoid } from 'nanoid';
+import type { INode } from 'n8n-workflow';
+
+import { test, expect } from '../../../../../fixtures/base';
+
+const TRIGGER_NAME = 'Manual Trigger';
+const SET_NAME = 'Edit Fields';
+
+const nodes: INode[] = [
+ {
+ id: nanoid(),
+ name: TRIGGER_NAME,
+ type: 'n8n-nodes-base.manualTrigger',
+ typeVersion: 1,
+ position: [0, 0],
+ parameters: {},
+ },
+ {
+ id: nanoid(),
+ name: SET_NAME,
+ type: 'n8n-nodes-base.set',
+ typeVersion: 3.4,
+ position: [200, 0],
+ parameters: {},
+ },
+];
+
+test.describe(
+ 'Engine type setting',
+ {
+ annotation: [{ type: 'owner', description: 'Catalysts' }],
+ },
+ () => {
+ test.beforeEach(async ({ n8n }) => {
+ // The Engine Type select shows only when the instance runs engine v2.
+ const activeModules = await n8n.api.getActiveModules();
+ test.skip(!activeModules.includes('engine-v2'), 'instance does not run engine v2');
+ });
+
+ test('runs the next manual execution on the engine the settings saved @engine:v2', async ({
+ n8n,
+ }) => {
+ const { id: workflowId } = await n8n.api.workflows.createWorkflow({
+ name: `Engine type ${nanoid()}`,
+ nodes,
+ connections: {
+ [TRIGGER_NAME]: { main: [[{ node: SET_NAME, type: 'main', index: 0 }]] },
+ },
+ pinData: {},
+ });
+ await n8n.start.fromExistingWorkflow(workflowId);
+
+ // v1 first: on the engine stack the API helper creates every workflow on v2,
+ // so only the v2 step that follows proves the dialog's save.
+ for (const { label, executionId } of [
+ { label: 'v1', executionId: /^\d+$/ },
+ { label: 'v2 (Beta)', executionId: UUID_V7_PATTERN },
+ ]) {
+ await n8n.workflowSettingsModal.open();
+ await n8n.workflowSettingsModal.selectEngineType(label);
+ await n8n.workflowSettingsModal.clickSave();
+ await expect(n8n.workflowSettingsModal.getModal()).toBeHidden();
+
+ const runResponse = n8n.page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ response.url().endsWith(`/rest/workflows/${workflowId}/run`),
+ );
+ await n8n.canvas.clickExecuteWorkflowButton();
+
+ const { data } = await (await runResponse).json();
+ expect(data.executionId, `the run after selecting ${label}`).toMatch(executionId);
+ }
+ });
+ },
+);
diff --git a/packages/quality/testing/playwright/tests/e2e/workflows/editor/execution/logs-layout.spec.ts b/packages/quality/testing/playwright/tests/e2e/workflows/editor/execution/logs-layout.spec.ts
new file mode 100644
index 00000000000..cd5d9ee5a91
--- /dev/null
+++ b/packages/quality/testing/playwright/tests/e2e/workflows/editor/execution/logs-layout.spec.ts
@@ -0,0 +1,132 @@
+import { nanoid } from 'nanoid';
+
+import { test, expect } from '../../../../../fixtures/base';
+
+test.use({ viewport: { width: 1280, height: 800 } });
+
+test.describe(
+ 'Logs panel node names',
+ { annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }] },
+ () => {
+ for (const nodeName of [
+ 'Code',
+ 'Generate Daily Summary Report',
+ 'Send WhatsApp Daily Summary',
+ ]) {
+ test(`should show "${nodeName}" in full beside execution output`, async ({ n8n, api }) => {
+ const workflow = await api.workflows.createWorkflow({
+ name: `Logs panel layout ${nanoid()}`,
+ nodes: [
+ {
+ id: nanoid(),
+ name: 'Start',
+ type: 'n8n-nodes-base.manualTrigger',
+ typeVersion: 1,
+ position: [240, 300],
+ parameters: {},
+ },
+ {
+ id: nanoid(),
+ name: nodeName,
+ type: 'n8n-nodes-base.noOp',
+ typeVersion: 1,
+ position: [480, 300],
+ parameters: {},
+ },
+ ],
+ connections: {
+ Start: { main: [[{ node: nodeName, type: 'main', index: 0 }]] },
+ },
+ });
+
+ try {
+ await n8n.start.fromExistingWorkflow(workflow.id);
+ const logs = n8n.canvas.logsPanel;
+ await logs.open();
+ await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
+ await expect(logs.getOverviewStatus()).toContainText('Success in');
+ await expect(logs.getSelectedLogEntry()).toContainText(nodeName);
+ await expect(logs.outputPanel.get()).toBeVisible();
+
+ const label = logs.getNodeName(nodeName);
+ await expect(label).toBeVisible();
+
+ // LIGO-884: Check rendered text, since an ellipsis does not change textContent.
+ await expect
+ .poll(
+ async () =>
+ await label.evaluate(async (element) => {
+ await element.ownerDocument.fonts.ready;
+ return element.scrollWidth - element.clientWidth;
+ }),
+ { message: `The default node list must show the full name "${nodeName}"` },
+ )
+ .toBe(0);
+ } finally {
+ await api.workflows.archive(workflow.id);
+ await api.workflows.delete(workflow.id);
+ }
+ });
+ }
+ },
+);
+
+test.describe(
+ 'Compact logs panel',
+ { annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }] },
+ () => {
+ test.use({ viewport: { width: 808, height: 800 } });
+
+ test('should reserve space for both input and output beside chat', async ({ n8n, api }) => {
+ const workflow = await api.workflows.createWorkflow({
+ name: `Compact logs panel layout ${nanoid()}`,
+ nodes: [
+ {
+ id: nanoid(),
+ name: 'Chat',
+ type: '@n8n/n8n-nodes-langchain.chatTrigger',
+ typeVersion: 1.3,
+ position: [240, 300],
+ parameters: { options: {} },
+ },
+ {
+ id: nanoid(),
+ name: 'Code',
+ type: 'n8n-nodes-base.noOp',
+ typeVersion: 1,
+ position: [480, 300],
+ parameters: {},
+ },
+ ],
+ connections: {
+ Chat: { main: [[{ node: 'Code', type: 'main', index: 0 }]] },
+ },
+ });
+
+ try {
+ await n8n.start.fromExistingWorkflow(workflow.id);
+ const logs = n8n.canvas.logsPanel;
+ await logs.open();
+ await expect(logs.getManualChatInput()).toBeVisible();
+ await logs.sendManualChatMessage('Compact layout');
+ await expect(logs.getOverviewStatus()).toContainText('Success in');
+ await expect(logs.getSelectedLogEntry()).toContainText('Code');
+ await expect(logs.outputPanel.get()).toBeVisible();
+ await logs.toggleInputPanel();
+ await expect(logs.inputPanel.get()).toBeVisible();
+ await expect(logs.outputPanel.get()).toBeVisible();
+
+ for (const panel of [logs.inputPanel, logs.outputPanel]) {
+ await expect
+ .poll(async () => (await panel.get().boundingBox())?.width, {
+ message: 'Each I/O pane must keep 200 px, allowing 1 px for the divider',
+ })
+ .toBeGreaterThanOrEqual(199);
+ }
+ } finally {
+ await api.workflows.archive(workflow.id);
+ await api.workflows.delete(workflow.id);
+ }
+ });
+ },
+);