Merge branch 'master' into ai-520-impact-of-test-executions-on-concurrency-limits

This commit is contained in:
Eugene Molodkin 2025-01-08 11:42:19 +01:00
commit b5625a07e2
No known key found for this signature in database
255 changed files with 12076 additions and 2014 deletions

View file

@ -32,25 +32,27 @@ component_management:
- packages/@n8n/api-types/**
- packages/@n8n/config/**
- packages/@n8n/client-oauth2/**
- packages/@n8n/di/**
- packages/@n8n/imap/**
- packages/@n8n/permissions/**
- packages/@n8n/task-runner/**
- packages/n8n-workflow/**
- packages/n8n-core/**
- packages/n8n-node-dev/**
- packages/n8n/**
- packages/workflow/**
- packages/core/**
- packages/cli/**
- component_id: frontend_packages
name: Frontend
paths:
- packages/@n8n/chat/**
- packages/@n8n/codemirror-lang/**
- packages/n8n-design-system/**
- packages/n8n-editor-ui/**
- packages/design-system/**
- packages/editor-ui/**
- component_id: nodes_packages
name: Nodes
paths:
- packages/n8n-nodes-base/**
- packages/@n8n/n8n-nodes-langchain/**
- packages/node-dev/**
- packages/nodes-base/**
- packages/@n8n/json-schema-to-zod/**
- packages/@n8n/nodes-langchain/**
ignore:
- (?s:.*/[^\/]*\.spec\.ts.*)\Z

View file

@ -46,7 +46,14 @@ export function getNodes() {
}
export function getNodeByName(name: string) {
return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
return cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0),
() => cy.getByTestId('canvas-node').filter(`[data-node-name="${name}"]`).eq(0),
);
}
export function getWorkflowHistoryCloseButton() {
return cy.getByTestId('workflow-history-close-button');
}
export function disableNode(name: string) {

View file

@ -1,15 +1,17 @@
import planData from '../fixtures/Plan_data_opt_in_trial.json';
import {
BannerStack,
MainSidebar,
WorkflowPage,
visitPublicApiPage,
getPublicApiUpgradeCTA,
WorkflowsPage,
} from '../pages';
const NUMBER_OF_AI_CREDITS = 100;
const mainSidebar = new MainSidebar();
const bannerStack = new BannerStack();
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
describe('Cloud', () => {
before(() => {
@ -22,6 +24,10 @@ describe('Cloud', () => {
cy.overrideSettings({
deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' },
aiCredits: {
enabled: true,
credits: NUMBER_OF_AI_CREDITS,
},
});
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
@ -40,11 +46,11 @@ describe('Cloud', () => {
it('should render trial banner for opt-in cloud user', () => {
visitWorkflowPage();
bannerStack.getters.banner().should('be.visible');
cy.getByTestId('banner-stack').should('be.visible');
mainSidebar.actions.signout();
bannerStack.getters.banner().should('not.be.visible');
cy.getByTestId('banner-stack').should('not.be.visible');
});
});
@ -64,4 +70,66 @@ describe('Cloud', () => {
getPublicApiUpgradeCTA().should('be.visible');
});
});
describe('Easy AI workflow experiment', () => {
it('should not show option to take you to the easy AI workflow if experiment is control', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '026_easy_ai_workflow': 'control' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('not.exist');
});
it('should show option to take you to the easy AI workflow if experiment is variant', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('to.exist');
});
it('should show default instructions if free AI credits experiment is control', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '027_free_openai_calls': 'control', '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text('Set up your OpenAI credentials in the OpenAI Model node');
});
});
it('should show updated instructions if free AI credits experiment is variant', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '027_free_openai_calls': 'variant', '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text(
`Claim your free ${NUMBER_OF_AI_CREDITS} OpenAI calls in the OpenAI model node`,
);
});
});
});
});

View file

@ -1,18 +1,14 @@
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
import {
CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../constants';
import {
WorkflowExecutionsTab,
WorkflowPage as WorkflowPageClass,
WorkflowHistoryPage,
} from '../pages';
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
const workflowHistoryPage = new WorkflowHistoryPage();
const createNewWorkflowAndActivate = () => {
workflowPage.actions.visit();
@ -92,7 +88,7 @@ const switchBetweenEditorAndHistory = () => {
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);
@ -168,7 +164,7 @@ describe('Editor actions should work', () => {
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);

View file

@ -3,6 +3,7 @@ import * as formStep from '../composables/setup-template-form-step';
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
import TestTemplate1 from '../fixtures/Test_Template_1.json';
import TestTemplate2 from '../fixtures/Test_Template_2.json';
import { clearNotifications } from '../pages/notifications';
import {
clickUseWorkflowButtonByTitle,
visitTemplateCollectionPage,
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Focus the canvas so the copy to clipboard works
workflowPage.getters.canvasNodes().eq(0).realClick();
workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
const workflow = JSON.parse(workflowJSON);
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
getSetupWorkflowCredentialsButton().should('be.visible');
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
setupCredsModal.closeModalFromContinueButton();
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');

View file

@ -1,22 +1,20 @@
import { SettingsPage } from '../pages/settings';
const settingsPage = new SettingsPage();
const url = '/settings';
describe('Admin user', { disableAutoLogin: true }, () => {
it('should see same Settings sub menu items as instance owner', () => {
cy.signinAsOwner();
cy.visit(settingsPage.url);
cy.visit(url);
let ownerMenuItems = 0;
settingsPage.getters.menuItems().then(($el) => {
cy.getByTestId('menu-item').then(($el) => {
ownerMenuItems = $el.length;
});
cy.signout();
cy.signinAsAdmin();
cy.visit(settingsPage.url);
cy.visit(url);
settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
cy.getByTestId('menu-item').should('have.length', ownerMenuItems);
});
});

View file

@ -517,7 +517,7 @@ describe('Node Creator', () => {
const actions = [
'Get ranked documents from vector store',
'Add documents to vector store',
'Retrieve documents for AI processing',
'Retrieve documents for Chain/Tool as Vector Store',
];
nodeCreatorFeature.actions.openNodeCreator();

View file

@ -40,7 +40,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
@ -64,7 +64,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param2');
getOutputPanelItemsCount().should('not.exist');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed but returned same data as input
@ -109,7 +109,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
@ -125,7 +125,7 @@ describe('Subworkflow debugging', () => {
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
'include.text',
'Inspect Parent Execution',
'View parent execution',
);
getExecutionPreviewOutputPanelRelatedExecutionLink()

View file

@ -57,7 +57,7 @@ for (const item of $input.all()) {
return
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
getParameter().get('.cm-lintRange-error').should('have.length', 6);
getParameter().contains('itemMatching').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
@ -81,7 +81,7 @@ $input.item()
return []
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
getParameter().get('.cm-lintRange-error').should('have.length', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',

View file

@ -19,7 +19,6 @@
"value": {},
"matchingColumns": [],
"schema": [],
"ignoreTypeMismatchErrors": false,
"attemptToConvertTypes": false,
"convertFieldsToString": true
},

View file

@ -1,9 +0,0 @@
import { BasePage } from './base';
export class BannerStack extends BasePage {
getters = {
banner: () => cy.getByTestId('banner-stack'),
};
actions = {};
}

View file

@ -1,5 +1,13 @@
import type { IE2ETestPage } from '../types';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class BasePage implements IE2ETestPage {
getters = {};

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class CredentialsPage extends BasePage {
url = '/home/credentials';

View file

@ -8,6 +8,14 @@ const AI_ASSISTANT_FEATURE = {
disabledFor: 'control',
};
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class AIAssistant extends BasePage {
url = '/workflows/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class NodeCreator extends BasePage {
url = '/workflow/new';

View file

@ -7,9 +7,7 @@ export * from './settings-users';
export * from './settings-log-streaming';
export * from './sidebar';
export * from './ndv';
export * from './bannerStack';
export * from './workflow-executions-tab';
export * from './signin';
export * from './workflow-history';
export * from './workerView';
export * from './settings-public-api';

View file

@ -3,6 +3,14 @@ import { SigninPage } from './signin';
import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MfaLoginPage extends BasePage {
url = '/mfa';

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class ChangePasswordModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -2,6 +2,14 @@ import { getCredentialSaveButton, saveCredential } from '../../composables/modal
import { getVisibleSelect } from '../../utils';
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class CredentialsModal extends BasePage {
getters = {
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
@ -61,6 +69,7 @@ export class CredentialsModal extends BasePage {
this.getters
.credentialInputs()
.find('input[type=text], input[type=password]')
.filter(':not([readonly])')
.each(($el) => {
cy.wrap($el).type('test');
});

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MessageBox extends BasePage {
getters = {
modal: () => cy.get('.el-message-box', { withinSubject: null }),

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MfaSetupModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowSharingModal extends BasePage {
getters = {
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),

View file

@ -1,6 +1,14 @@
import { BasePage } from './base';
import { getVisiblePopper, getVisibleSelect } from '../utils';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class NDV extends BasePage {
getters = {
container: () => cy.getByTestId('ndv'),

View file

@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
* Actions
*/
export const clearNotifications = () => {
successToast().find('.el-notification__closeBtn').click({ multiple: true });
const buttons = successToast().find('.el-notification__closeBtn');
buttons.then(($buttons) => {
if ($buttons.length) {
buttons.click({ multiple: true });
}
});
};

View file

@ -1,6 +1,14 @@
import { BasePage } from './base';
import { getVisibleSelect } from '../utils';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsLogStreamingPage extends BasePage {
url = '/settings/log-streaming';

View file

@ -7,6 +7,14 @@ import { MfaSetupModal } from './modals/mfa-setup-modal';
const changePasswordModal = new ChangePasswordModal();
const mfaSetupModal = new MfaSetupModal();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class PersonalSettingsPage extends BasePage {
url = '/settings/personal';

View file

@ -1,9 +0,0 @@
import { BasePage } from './base';
export class SettingsUsagePage extends BasePage {
url = '/settings/usage';
getters = {};
actions = {};
}

View file

@ -9,6 +9,14 @@ const workflowsPage = new WorkflowsPage();
const mainSidebar = new MainSidebar();
const settingsSidebar = new SettingsSidebar();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsUsersPage extends BasePage {
url = '/settings/users';

View file

@ -1,11 +0,0 @@
import { BasePage } from './base';
export class SettingsPage extends BasePage {
url = '/settings';
getters = {
menuItems: () => cy.getByTestId('menu-item'),
};
actions = {};
}

View file

@ -1,6 +1,14 @@
import { BasePage } from '../base';
import { WorkflowsPage } from '../workflows';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MainSidebar extends BasePage {
getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsSidebar extends BasePage {
getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SigninPage extends BasePage {
url = '/signin';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class TemplatesPage extends BasePage {
url = '/templates';

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import Chainable = Cypress.Chainable;
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class VariablesPage extends BasePage {
url = '/variables';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkerViewPage extends BasePage {
url = '/settings/workers';

View file

@ -3,6 +3,14 @@ import { WorkflowPage } from './workflow';
const workflowPage = new WorkflowPage();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowExecutionsTab extends BasePage {
getters = {
executionsTabButton: () => cy.getByTestId('radio-button-executions'),

View file

@ -1,7 +0,0 @@
import { BasePage } from './base';
export class WorkflowHistoryPage extends BasePage {
getters = {
workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'),
};
}

View file

@ -6,6 +6,15 @@ import { getVisibleSelect } from '../utils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
const nodeCreator = new NodeCreator();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowPage extends BasePage {
url = '/workflow/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowsPage extends BasePage {
url = '/home/workflows';

View file

@ -39,13 +39,23 @@ export class TaskRunnersConfig {
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
maxOldSpaceSize: string = '';
/** How many concurrent tasks can a runner execute at a time */
/**
* How many concurrent tasks can a runner execute at a time
*
* @note Kept high for backwards compatibility - n8n v2 will reduce this to `5`
*/
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
maxConcurrency: number = 5;
maxConcurrency: number = 10;
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
/**
* How long (in seconds) a task is allowed to take for completion, else the
* task will be aborted. (In internal mode, the runner will also be
* restarted.) Must be greater than 0.
*
* @note Kept high for backwards compatibility - n8n v2 will reduce this to `60`
*/
@Env('N8N_RUNNERS_TASK_TIMEOUT')
taskTimeout: number = 60;
taskTimeout: number = 300; // 5 minutes
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')

View file

@ -229,8 +229,8 @@ describe('GlobalConfig', () => {
maxPayload: 1024 * 1024 * 1024,
port: 5679,
maxOldSpaceSize: '',
maxConcurrency: 5,
taskTimeout: 60,
maxConcurrency: 10,
taskTimeout: 300,
heartbeatInterval: 30,
},
sentry: {

View file

@ -131,6 +131,7 @@ export class LmChatGoogleVertex implements INodeType {
const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const email = (credentials.email as string).trim();
const region = credentials.region as string;
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
@ -165,6 +166,7 @@ export class LmChatGoogleVertex implements INodeType {
private_key: privateKey,
},
},
location: region,
model: modelName,
topK: options.topK,
topP: options.topP,

View file

@ -0,0 +1,73 @@
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { VectorStoreZep } from './VectorStoreZep.node';
describe('VectorStoreZep', () => {
const vectorStore = new VectorStoreZep();
const helpers = mock<ISupplyDataFunctions['helpers']>();
const executeFunctions = mock<ISupplyDataFunctions>({ helpers });
beforeEach(() => {
jest.resetAllMocks();
executeFunctions.addInputData.mockReturnValue({ index: 0 });
});
it('should get vector store cloud client', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'mode':
return 'retrieve';
case 'collectionName':
return 'test-collection';
case 'options':
return {};
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue(
mock({
apiKey: 'some-key',
cloud: true,
}),
);
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
expect(response).toBeDefined();
expect(response).toBeInstanceOf(ZepCloudVectorStore);
});
it('should get vector store self-hosted client', async () => {
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'mode':
return 'retrieve';
case 'collectionName':
return 'test-collection';
case 'options':
return {};
default:
return undefined;
}
});
executeFunctions.getCredentials.mockResolvedValue(
mock({
apiKey: 'some-key',
apiUrl: 'https://example.com',
cloud: false,
}),
);
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
expect(response).toBeDefined();
expect(response).toBeInstanceOf(ZepVectorStore);
});
});

View file

@ -1,5 +1,5 @@
import type { IZepConfig } from '@langchain/community/vectorstores/zep';
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
import type { IDataObject, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
@ -84,17 +84,21 @@ export class VectorStoreZep extends createVectorStoreNode({
const credentials = await context.getCredentials<{
apiKey?: string;
apiUrl: string;
cloud: boolean;
}>('zepApi');
const zepConfig: IZepConfig = {
apiUrl: credentials.apiUrl,
const zepConfig = {
apiKey: credentials.apiKey,
collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536,
metadata: filter,
};
return new ZepVectorStore(embeddings, zepConfig);
if (credentials.cloud) {
return new ZepCloudVectorStore(embeddings, zepConfig);
} else {
return new ZepVectorStore(embeddings, { ...zepConfig, apiUrl: credentials.apiUrl });
}
},
async populateVectorStore(context, embeddings, documents, itemIndex) {
const collectionName = context.getNodeParameter('collectionName', itemIndex) as string;
@ -107,10 +111,10 @@ export class VectorStoreZep extends createVectorStoreNode({
const credentials = await context.getCredentials<{
apiKey?: string;
apiUrl: string;
cloud: boolean;
}>('zepApi');
const zepConfig = {
apiUrl: credentials.apiUrl,
apiKey: credentials.apiKey,
collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536,
@ -118,7 +122,14 @@ export class VectorStoreZep extends createVectorStoreNode({
};
try {
await ZepVectorStore.fromDocuments(documents, embeddings, zepConfig);
if (credentials.cloud) {
await ZepCloudVectorStore.fromDocuments(documents, embeddings, zepConfig);
} else {
await ZepVectorStore.fromDocuments(documents, embeddings, {
...zepConfig,
apiUrl: credentials.apiUrl,
});
}
} catch (error) {
const errorCode = (error as IDataObject).code as number;
const responseData = (error as IDataObject).responseData as string;

View file

@ -0,0 +1,234 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
{
"codex": {
"categories": [
"AI",
],
"resources": {
"primaryDocumentation": [
{
"url": undefined,
},
],
},
"subcategories": {
"AI": [
"Vector Stores",
"Tools",
"Root Nodes",
],
"Tools": [
"Other Tools",
],
},
},
"credentials": undefined,
"defaults": {
"name": undefined,
},
"description": undefined,
"displayName": undefined,
"group": [
"transform",
],
"icon": undefined,
"iconColor": undefined,
"inputs": "={{
((parameters) => {
const mode = parameters?.mode;
const inputs = [{ displayName: "Embedding", type: "ai_embedding", required: true, maxConnections: 1}]
if (mode === 'retrieve-as-tool') {
return inputs;
}
if (['insert', 'load', 'update'].includes(mode)) {
inputs.push({ displayName: "", type: "main"})
}
if (['insert'].includes(mode)) {
inputs.push({ displayName: "Document", type: "ai_document", required: true, maxConnections: 1})
}
return inputs
})($parameter)
}}",
"name": "mockConstructor",
"outputs": "={{
((parameters) => {
const mode = parameters?.mode ?? 'retrieve';
if (mode === 'retrieve-as-tool') {
return [{ displayName: "Tool", type: "ai_tool"}]
}
if (mode === 'retrieve') {
return [{ displayName: "Vector Store", type: "ai_vectorStore"}]
}
return [{ displayName: "", type: "main"}]
})($parameter)
}}",
"properties": [
{
"default": "retrieve",
"displayName": "Operation Mode",
"name": "mode",
"noDataExpression": true,
"options": [
{
"action": "Get ranked documents from vector store",
"description": "Get many ranked documents from vector store for query",
"name": "Get Many",
"value": "load",
},
{
"action": "Add documents to vector store",
"description": "Insert documents into vector store",
"name": "Insert Documents",
"value": "insert",
},
{
"action": "Retrieve documents for Chain/Tool as Vector Store",
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
"name": "Retrieve Documents (As Vector Store for Chain/Tool)",
"outputConnectionType": "ai_vectorStore",
"value": "retrieve",
},
{
"action": "Retrieve documents for AI Agent as Tool",
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
"name": "Retrieve Documents (As Tool for AI Agent)",
"outputConnectionType": "ai_tool",
"value": "retrieve-as-tool",
},
],
"type": "options",
},
{
"default": "",
"displayName": "This node must be connected to a vector store retriever. <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='ai_retriever'>Insert one</a>",
"displayOptions": {
"show": {
"mode": [
"retrieve",
],
},
},
"name": "notice",
"type": "notice",
"typeOptions": {
"containerClass": "ndv-connection-hint-notice",
},
},
{
"default": "",
"description": "Name of the vector store",
"displayName": "Name",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
"name": "toolName",
"placeholder": "e.g. company_knowledge_base",
"required": true,
"type": "string",
"validateType": "string-alphanumeric",
},
{
"default": "",
"description": "Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often",
"displayName": "Description",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
"name": "toolDescription",
"placeholder": "e.g. undefined",
"required": true,
"type": "string",
"typeOptions": {
"rows": 2,
},
},
{
"default": "",
"description": "Search prompt to retrieve matching documents from the vector store using similarity-based ranking",
"displayName": "Prompt",
"displayOptions": {
"show": {
"mode": [
"load",
],
},
},
"name": "prompt",
"required": true,
"type": "string",
},
{
"default": 4,
"description": "Number of top results to fetch from vector store",
"displayName": "Limit",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "topK",
"type": "number",
},
{
"default": true,
"description": "Whether or not to include document metadata",
"displayName": "Include Metadata",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "includeDocumentMetadata",
"type": "boolean",
},
{
"default": "",
"description": "ID of an embedding entry",
"displayName": "ID",
"displayOptions": {
"show": {
"mode": [
"update",
],
},
},
"name": "id",
"required": true,
"type": "string",
},
{
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "loadField",
},
],
"version": 1,
}
`;

View file

@ -49,7 +49,11 @@ describe('createVectorStoreNode', () => {
const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
sharedFields: [],
insertFields: [],
loadFields: [],
loadFields: [
{
name: 'loadField',
},
],
retrieveFields: [],
updateFields: [],
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
@ -82,6 +86,7 @@ describe('createVectorStoreNode', () => {
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
// ASSERT
expect(nodeType.description).toMatchSnapshot();
expect(wrappedVectorStore).toEqual(vectorStore);
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
});

View file

@ -80,10 +80,13 @@ export interface VectorStoreNodeConstructorArgs {
) => Promise<VectorStore>;
}
function transformDescriptionForOperationMode(fields: INodeProperties[], mode: NodeOperationMode) {
function transformDescriptionForOperationMode(
fields: INodeProperties[],
mode: NodeOperationMode | NodeOperationMode[],
) {
return fields.map((field) => ({
...field,
displayOptions: { show: { mode: [mode] } },
displayOptions: { show: { mode: Array.isArray(mode) ? mode : [mode] } },
}));
}
@ -108,17 +111,17 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
action: 'Add documents to vector store',
},
{
name: 'Retrieve Documents (As Vector Store for AI Agent)',
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
value: 'retrieve',
description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
action: 'Retrieve documents for AI processing as Vector Store',
action: 'Retrieve documents for Chain/Tool as Vector Store',
outputConnectionType: NodeConnectionType.AiVectorStore,
},
{
name: 'Retrieve Documents (As Tool for AI Agent)',
value: 'retrieve-as-tool',
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
action: 'Retrieve documents for AI processing as Tool',
action: 'Retrieve documents for AI Agent as Tool',
outputConnectionType: NodeConnectionType.AiTool,
},
{
@ -299,7 +302,10 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
},
},
},
...transformDescriptionForOperationMode(args.loadFields ?? [], 'load'),
...transformDescriptionForOperationMode(args.loadFields ?? [], [
'load',
'retrieve-as-tool',
]),
...transformDescriptionForOperationMode(args.retrieveFields ?? [], 'retrieve'),
...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'),
],

View file

@ -28,6 +28,7 @@ describe('result validation', () => {
['binary', {}],
['pairedItem', {}],
['error', {}],
['index', {}], // temporarily allowed until refactored out
])(
'should not throw an error if the output item has %s key in addition to json',
(key, value) => {

View file

@ -4,7 +4,19 @@ import type { INodeExecutionData } from 'n8n-workflow';
import { ValidationError } from './errors/validation-error';
import { isObject } from './obj-utils';
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']);
export const REQUIRED_N8N_ITEM_KEYS = new Set([
'json',
'binary',
'pairedItem',
'error',
/**
* The `index` key was added accidentally to Function, FunctionItem, Gong,
* Execute Workflow, and ToolWorkflowV2, so we need to allow it temporarily.
* Once we stop using it in all nodes, we can stop allowing the `index` key.
*/
'index',
]);
function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
for (const key in item) {

View file

@ -195,4 +195,3 @@ export const WsStatusCodes = {
} as const;
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';

View file

@ -6,10 +6,11 @@ import {
} from '@n8n/api-types';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web';
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { Body, Post, RestController } from '@/decorators';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';

View file

@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
if (credential) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.credentialsService.decrypt(credential);
decryptedData = this.credentialsService.decrypt(credential, true);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.

View file

@ -542,7 +542,7 @@ export class CredentialsService {
if (sharing) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.decrypt(sharing.credentials);
decryptedData = this.decrypt(sharing.credentials, true);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.

View file

@ -9,7 +9,8 @@ import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = {
name: string;
name?: string;
id: string;
};
/**

View file

@ -35,4 +35,23 @@ export class TestRun extends WithTimestampsAndStringId {
@Column(jsonColumnType, { nullable: true })
metrics: AggregatedTestRunMetrics;
/**
* Total number of the test cases, matching the filter condition of the test definition (specified annotationTag)
*/
@Column('integer', { nullable: true })
totalCases: number;
/**
* Number of test cases that passed (evaluation workflow was executed successfully)
*/
@Column('integer', { nullable: true })
passedCases: number;
/**
* Number of failed test cases
* (any unexpected exception happened during the execution or evaluation workflow ended with an error)
*/
@Column('integer', { nullable: true })
failedCases: number;
}

View file

@ -0,0 +1,31 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
const columns = ['totalCases', 'passedCases', 'failedCases'] as const;
export class AddStatsColumnsToTestRun1736172058779 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
// Values can be NULL only if the test run is new, otherwise they must be non-negative integers.
// Test run might be cancelled or interrupted by unexpected error at any moment, so values can be either NULL or non-negative integers.
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${name} INT CHECK(
CASE
WHEN status = 'new' THEN ${name} IS NULL
WHEN status in ('cancelled', 'error') THEN ${name} IS NULL OR ${name} >= 0
ELSE ${name} >= 0
END
)`);
}
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${name}`);
}
}
}

View file

@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -154,4 +155,5 @@ export const mysqlMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
];

View file

@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@ -154,4 +155,5 @@ export const postgresMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
];

View file

@ -73,6 +73,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -148,6 +149,7 @@ const sqliteMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
];
export { sqliteMigrations };

View file

@ -21,14 +21,28 @@ export class TestRunRepository extends Repository<TestRun> {
return await this.save(testRun);
}
async markAsRunning(id: string) {
return await this.update(id, { status: 'running', runAt: new Date() });
async markAsRunning(id: string, totalCases: number) {
return await this.update(id, {
status: 'running',
runAt: new Date(),
totalCases,
passedCases: 0,
failedCases: 0,
});
}
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
}
async incrementPassed(id: string) {
return await this.increment({ id }, 'passedCases', 1);
}
async incrementFailed(id: string) {
return await this.increment({ id }, 'failedCases', 1);
}
async getMany(testDefinitionId: string, options: ListQuery.Options) {
const findManyOptions: FindManyOptions<TestRun> = {
where: { testDefinition: { id: testDefinitionId } },

View file

@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: z.string().min(1).optional(),
mockedNodes: z.array(z.object({ name: z.string() })).optional(),
mockedNodes: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
})
.strict();

View file

@ -121,13 +121,26 @@ export class TestDefinitionService {
relations: ['workflow'],
});
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name));
const existingNodeNames = new Map(
existingTestDefinition.workflow.nodes.map((n) => [n.name, n]),
);
const existingNodeIds = new Map(existingTestDefinition.workflow.nodes.map((n) => [n.id, n]));
attrs.mockedNodes.forEach((node) => {
if (!existingNodeNames.has(node.name)) {
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(node.name))) {
throw new BadRequestError(
`Pinned node not found in the workflow: ${node.id} (${node.name})`,
);
}
});
// Update the node names OR node ids if they are not provided
attrs.mockedNodes = attrs.mockedNodes.map((node) => {
return {
id: node.id ?? (node.name && existingNodeNames.get(node.name)?.id),
name: node.name ?? (node.id && existingNodeIds.get(node.id)?.name),
};
});
}
// Update the test definition

View file

@ -7,13 +7,24 @@ const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
);
const wfUnderTestRenamedNodesJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
encoding: 'utf-8',
}),
);
const executionDataJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
);
describe('createPinData', () => {
test('should create pin data from past execution data', () => {
const mockedNodes = ['When clicking Test workflow'].map((name) => ({ name }));
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af',
name: 'When clicking Test workflow',
},
];
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -25,7 +36,7 @@ describe('createPinData', () => {
});
test('should not create pin data for non-existing mocked nodes', () => {
const mockedNodes = ['Non-existing node'].map((name) => ({ name }));
const mockedNodes = ['non-existing-ID'].map((id) => ({ id }));
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -33,9 +44,17 @@ describe('createPinData', () => {
});
test('should create pin data for all mocked nodes', () => {
const mockedNodes = ['When clicking Test workflow', 'Edit Fields', 'Code'].map((name) => ({
name,
}));
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'When clicking Test workflow'
},
{
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Edit Fields'
},
{
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
},
];
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -53,4 +72,33 @@ describe('createPinData', () => {
expect(pinData).toEqual({});
});
test('should create pin data for all mocked nodes with renamed nodes', () => {
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'Manual Run'
},
{
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Set Attribute'
},
{
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
},
];
const pinData = createPinData(
wfUnderTestRenamedNodesJson,
mockedNodes,
executionDataJson,
wfUnderTestJson, // Pass original workflow JSON as pastWorkflowData
);
expect(pinData).toEqual(
expect.objectContaining({
'Manual Run': expect.anything(),
'Set Attribute': expect.anything(),
Code: expect.anything(),
}),
);
});
});

View file

@ -0,0 +1,78 @@
{
"name": "Workflow Under Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, 0],
"id": "72256d90-3a67-4e29-b032-47df4e5768af",
"name": "Manual Run"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "acfeecbe-443c-4220-b63b-d44d69216902",
"name": "foo",
"value": "bar",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [140, 0],
"id": "319f29bc-1dd4-4122-b223-c584752151a4",
"name": "Set Attribute"
},
{
"parameters": {
"jsCode": "for (const item of $input.all()) {\n item.json.random = Math.random();\n}\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [380, 0],
"id": "d2474215-63af-40a4-a51e-0ea30d762621",
"name": "Code"
}
],
"connections": {
"Manual Run": {
"main": [
[
{
"node": "Set attribute",
"type": "main",
"index": 0
}
]
]
},
"Set attribute": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -2,7 +2,8 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
import { stringify } from 'flatted';
import { readFileSync } from 'fs';
import { mock, mockDeep } from 'jest-mock-extended';
import type { GenericValue, IRun } from 'n8n-workflow';
import type { ErrorReporter } from 'n8n-core';
import type { ExecutionError, GenericValue, IRun } from 'n8n-workflow';
import path from 'path';
import type { ActiveExecutions } from '@/active-executions';
@ -27,6 +28,12 @@ const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
);
const wfUnderTestRenamedNodesJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
encoding: 'utf-8',
}),
);
const wfEvaluationJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
);
@ -60,6 +67,7 @@ const executionMocks = [
status: 'success',
executionData: {
data: stringify(executionDataJson),
workflowData: wfUnderTestJson,
},
}),
mock<ExecutionEntity>({
@ -68,6 +76,7 @@ const executionMocks = [
status: 'success',
executionData: {
data: stringify(executionDataJson),
workflowData: wfUnderTestRenamedNodesJson,
},
}),
];
@ -82,6 +91,16 @@ function mockExecutionData() {
});
}
function mockErrorExecutionData() {
return mock<IRun>({
data: {
resultData: {
error: mock<ExecutionError>(),
},
},
});
}
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
return mock<IRun>({
data: {
@ -102,6 +121,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
},
],
},
// error is an optional prop, but jest-mock-extended will mock it by default,
// which affects the code logic. So, we need to explicitly set it to undefined.
error: undefined,
},
},
});
@ -148,6 +170,8 @@ describe('TestRunnerService', () => {
testRunRepository.createTestRun.mockClear();
testRunRepository.markAsRunning.mockClear();
testRunRepository.markAsCompleted.mockClear();
testRunRepository.incrementFailed.mockClear();
testRunRepository.incrementPassed.mockClear();
});
test('should create an instance of TestRunnerService', async () => {
@ -159,6 +183,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -173,6 +198,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -210,6 +236,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -250,7 +277,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ name: 'When clicking Test workflow' }],
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}),
);
@ -290,12 +317,185 @@ describe('TestRunnerService', () => {
// Check Test Run status was updated correctly
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
metric1: 0.75,
metric2: 0,
});
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
});
test('should properly count passed and failed executions', async () => {
const testRunnerService = new TestRunnerService(
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock executions of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockResolvedValue(mockExecutionData());
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-4')
.mockRejectedValue(new Error('Some error'));
await testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [],
}),
);
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
});
test('should properly count failed test executions', async () => {
const testRunnerService = new TestRunnerService(
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock executions of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockResolvedValue(mockErrorExecutionData());
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
await testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [],
}),
);
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
});
test('should properly count failed evaluations', async () => {
const testRunnerService = new TestRunnerService(
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock executions of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockResolvedValue(mockExecutionData());
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-4')
.mockResolvedValue(mockErrorExecutionData());
await testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [],
}),
);
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
});
test('should specify correct start nodes when running workflow under test', async () => {
@ -307,6 +507,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -347,7 +548,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ name: 'When clicking Test workflow' }],
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}),
);
@ -380,6 +581,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
const startNodesData = (testRunnerService as any).getStartNodesData(
@ -404,6 +606,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
const startNodesData = (testRunnerService as any).getStartNodesData(

View file

@ -1,13 +1,15 @@
import { Service } from '@n8n/di';
import { parse } from 'flatted';
import { ErrorReporter } from 'n8n-core';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import type {
IDataObject,
IRun,
IRunData,
IRunExecutionData,
IWorkflowBase,
IWorkflowExecutionDataProcess,
} from 'n8n-workflow';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import assert from 'node:assert';
import { ActiveExecutions } from '@/active-executions';
@ -44,6 +46,7 @@ export class TestRunnerService {
private readonly testRunRepository: TestRunRepository,
private readonly testMetricRepository: TestMetricRepository,
private readonly nodeTypes: NodeTypes,
private readonly errorReporter: ErrorReporter,
) {}
/**
@ -94,11 +97,17 @@ export class TestRunnerService {
private async runTestCase(
workflow: WorkflowEntity,
pastExecutionData: IRunExecutionData,
pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[],
userId: string,
): Promise<IRun | undefined> {
// Create pin data from the past execution data
const pinData = createPinData(workflow, mockedNodes, pastExecutionData);
const pinData = createPinData(
workflow,
mockedNodes,
pastExecutionData,
pastExecutionWorkflowData,
);
// Prepare the data to run the workflow
const data: IWorkflowExecutionDataProcess = {
@ -127,6 +136,7 @@ export class TestRunnerService {
evaluationWorkflow: WorkflowEntity,
expectedData: IRunData,
actualData: IRunData,
testRunId?: string,
) {
// Prepare the evaluation wf input data.
// Provide both the expected data and the actual data
@ -139,7 +149,13 @@ export class TestRunnerService {
// Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
// So that we can fetch all execution runs for a test run
if (testRunId && data.executionData) {
data.executionData.resultData.metadata = {
testRunId,
};
}
data.executionMode = 'evaluation';
// Trigger the evaluation workflow
@ -216,51 +232,66 @@ export class TestRunnerService {
// 2. Run over all the test cases
await this.testRunRepository.markAsRunning(testRun.id);
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
// Object to collect the results of the evaluation workflow executions
const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) {
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
where: { id: pastExecutionId },
relations: ['executionData', 'metadata'],
});
assert(pastExecution, 'Execution not found');
try {
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
where: { id: pastExecutionId },
relations: ['executionData', 'metadata'],
});
assert(pastExecution, 'Execution not found');
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
// Run the test case and wait for it to finish
const testCaseExecution = await this.runTestCase(
workflow,
executionData,
test.mockedNodes,
user.id,
);
// Run the test case and wait for it to finish
const testCaseExecution = await this.runTestCase(
workflow,
executionData,
pastExecution.executionData.workflowData,
test.mockedNodes,
user.id,
);
// In case of a permission check issue, the test case execution will be undefined.
// Skip them and continue with the next test case
if (!testCaseExecution) {
continue;
// In case of a permission check issue, the test case execution will be undefined.
// Skip them, increment the failed count and continue with the next test case
if (!testCaseExecution) {
await this.testRunRepository.incrementFailed(testRun.id);
continue;
}
// Collect the results of the test case execution
const testCaseRunData = testCaseExecution.data.resultData.runData;
// Get the original runData from the test case execution data
const originalRunData = executionData.resultData.runData;
// Run the evaluation workflow with the original and new run data
const evalExecution = await this.runTestCaseEvaluation(
evaluationWorkflow,
originalRunData,
testCaseRunData,
testRun.id,
);
assert(evalExecution);
metrics.addResults(this.extractEvaluationResult(evalExecution));
if (evalExecution.data.resultData.error) {
await this.testRunRepository.incrementFailed(testRun.id);
} else {
await this.testRunRepository.incrementPassed(testRun.id);
}
} catch (e) {
// In case of an unexpected error, increment the failed count and continue with the next test case
await this.testRunRepository.incrementFailed(testRun.id);
this.errorReporter.error(e);
}
// Collect the results of the test case execution
const testCaseRunData = testCaseExecution.data.resultData.runData;
// Get the original runData from the test case execution data
const originalRunData = executionData.resultData.runData;
// Run the evaluation workflow with the original and new run data
const evalExecution = await this.runTestCaseEvaluation(
evaluationWorkflow,
originalRunData,
testCaseRunData,
);
assert(evalExecution);
// Extract the output of the last node executed in the evaluation workflow
metrics.addResults(this.extractEvaluationResult(evalExecution));
}
const aggregatedMetrics = metrics.getAggregatedMetrics();

View file

@ -1,4 +1,5 @@
import type { IRunExecutionData, IPinData } from 'n8n-workflow';
import assert from 'assert';
import type { IRunExecutionData, IPinData, IWorkflowBase } from 'n8n-workflow';
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -13,16 +14,33 @@ export function createPinData(
workflow: WorkflowEntity,
mockedNodes: MockedNodeItem[],
executionData: IRunExecutionData,
pastWorkflowData?: IWorkflowBase,
) {
const pinData = {} as IPinData;
const workflowNodeNames = new Set(workflow.nodes.map((node) => node.name));
const workflowNodeIds = new Map(workflow.nodes.map((node) => [node.id, node.name]));
// If the past workflow data is provided, use it to create a map between node IDs and node names
const pastWorkflowNodeIds = new Map<string, string>();
if (pastWorkflowData) {
for (const node of pastWorkflowData.nodes) {
pastWorkflowNodeIds.set(node.id, node.name);
}
}
for (const mockedNode of mockedNodes) {
if (workflowNodeNames.has(mockedNode.name)) {
const nodeData = executionData.resultData.runData[mockedNode.name];
assert(mockedNode.id, 'Mocked node ID is missing');
const nodeName = workflowNodeIds.get(mockedNode.id);
// If mocked node is still present in the workflow
if (nodeName) {
// Try to restore node name from past execution data (it might have been renamed between past execution and up-to-date workflow)
const pastNodeName = pastWorkflowNodeIds.get(mockedNode.id) ?? nodeName;
const nodeData = executionData.resultData.runData[pastNodeName];
if (nodeData?.[0]?.data?.main?.[0]) {
pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0];
pinData[nodeName] = nodeData[0]?.data?.main?.[0];
}
}
}

View file

@ -5,7 +5,9 @@ import type { INode, INodesGraphResult } from 'n8n-workflow';
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => {
const nodeTypes = mock<NodeTypes>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const projectRelationRepository = mock<ProjectRelationRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const eventService = new EventService();
let telemetryEventRelay: TelemetryEventRelay;
@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
await telemetryEventRelay.init();
@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
// @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
// @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => {
it('should call telemetry.track when manual node execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
);
const runData = {
status: 'error',
@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => {
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
is_managed: false,
credential_type: null,
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
@ -1498,5 +1509,187 @@ describe('TelemetryEventRelay', () => {
}),
);
});
it('should call telemetry.track when manual node execution finished with is_managed and credential_type properties', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);
const runData = {
status: 'error',
mode: 'manual',
data: {
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as unknown as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
id: 'nhu-l8E4hX',
});
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'sharee',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
is_managed: true,
credential_type: 'openAiApi',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when user ran out of free AI credits', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);
const runData = {
status: 'error',
mode: 'trigger',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
jest
.spyOn(TelemetryHelpers, 'userInInstanceRanOutOfFreeAiCredits')
.mockImplementation(() => true);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith('User ran out of free AI credits');
});
});
});

View file

@ -9,6 +9,7 @@ import { get as pslGet } from 'psl';
import config from '@/config';
import { N8N_VERSION } from '@/constants';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay {
private readonly nodeTypes: NodeTypes,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly credentialsRepository: CredentialsRepository,
) {
super(eventService);
}
@ -632,6 +634,10 @@ export class TelemetryEventRelay extends EventRelay {
let nodeGraphResult: INodesGraphResult | null = null;
if (!telemetryProperties.success && runData?.data.resultData.error) {
if (TelemetryHelpers.userInInstanceRanOutOfFreeAiCredits(runData)) {
this.telemetry.track('User ran out of free AI credits');
}
telemetryProperties.error_message = runData?.data.resultData.error.message;
let errorNodeName =
'node' in runData?.data.resultData.error
@ -693,6 +699,8 @@ export class TelemetryEventRelay extends EventRelay {
error_node_id: telemetryProperties.error_node_id as string,
webhook_domain: null,
sharing_role: userRole,
credential_type: null,
is_managed: false,
};
if (!manualExecEventProperties.node_graph_string) {
@ -703,7 +711,18 @@ export class TelemetryEventRelay extends EventRelay {
}
if (runData.data.startData?.destinationNode) {
const telemetryPayload = {
const credentialsData = TelemetryHelpers.extractLastExecutedNodeCredentialData(runData);
if (credentialsData) {
manualExecEventProperties.credential_type = credentialsData.credentialType;
const credential = await this.credentialsRepository.findOneBy({
id: credentialsData.credentialId,
});
if (credential) {
manualExecEventProperties.is_managed = credential.isManaged;
}
}
const telemetryPayload: ITelemetryTrackProperties = {
...manualExecEventProperties,
node_type: TelemetryHelpers.getNodeTypeForName(
workflow,

View file

@ -2,6 +2,7 @@ import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import compression from 'compression';
import express from 'express';
import { rateLimit as expressRateLimit } from 'express-rate-limit';
import { Logger } from 'n8n-core';
import * as a from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
@ -147,8 +148,16 @@ export class TaskRunnerServer {
}
private configureRoutes() {
const createRateLimiter = () =>
expressRateLimit({
windowMs: 1000,
limit: 5,
message: { message: 'Too many requests' },
});
this.app.use(
this.upgradeEndpoint,
createRateLimiter(),
// eslint-disable-next-line @typescript-eslint/unbound-method
this.taskRunnerAuthController.authMiddleware,
(req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) =>
@ -158,6 +167,7 @@ export class TaskRunnerServer {
const authEndpoint = `${this.getEndpointBasePath()}/auth`;
this.app.post(
authEndpoint,
createRateLimiter(),
send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)),
);

View file

@ -1,8 +1,9 @@
import { Container } from '@n8n/di';
import { randomUUID } from 'crypto';
import { mock } from 'jest-mock-extended';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';

View file

@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
import { In } from '@n8n/typeorm';
import config from '@/config';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
@ -555,6 +556,22 @@ describe('GET /credentials/:id', () => {
expect(secondCredential.data).toBeDefined();
});
test('should not redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: owner,
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled();
});
test('should retrieve non-owned cred for owner', async () => {
const [member1, member2] = await createManyUsers(2, {
role: 'global:member',

View file

@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
import { Credentials } from 'n8n-core';
import { randomString } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
@ -1272,6 +1273,23 @@ describe('GET /credentials/:id', () => {
expect(secondResponse.body.data.data).toBeDefined();
});
test('should not redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled();
});
test('should retrieve owned cred for member', async () => {
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: member,

View file

@ -405,13 +405,14 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [
{
id: 'uuid-1234',
name: 'Schedule Trigger',
},
],
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]);
expect(resp.body.data.mockedNodes).toEqual([{ id: 'uuid-1234', name: 'Schedule Trigger' }]);
});
test('should return error if pinned nodes are invalid', async () => {

View file

@ -93,7 +93,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
const testRunRepository = Container.get(TestRunRepository);
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
// Mark as running just to make a slight delay between the runs
await testRunRepository.markAsRunning(testRun1.id);
await testRunRepository.markAsRunning(testRun1.id, 10);
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
// Fetch the first page

View file

@ -19,4 +19,26 @@ describe('TaskRunnerServer', () => {
await agent.get('/healthz').expect(200);
});
});
describe('/runners/_ws', () => {
it('should return 429 when too many requests are made', async () => {
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(429);
});
});
describe('/runners/auth', () => {
it('should return 429 when too many requests are made', async () => {
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(429);
});
});
});

View file

@ -61,11 +61,7 @@ const validateResourceMapperValue = (
});
if (!validationResult.valid) {
if (!resourceMapperField.ignoreTypeMismatchErrors) {
return { ...validationResult, fieldName: key };
} else {
paramValues[key] = resolvedValue;
}
return { ...validationResult, fieldName: key };
} else {
// If it's valid, set the casted value
paramValues[key] = validationResult.newValue;

View file

@ -34,7 +34,11 @@ const classes = computed(() => ({
<slot name="footer" />
</div>
</div>
<div v-if="$slots.append" data-test-id="card-append" :class="$style.append">
<div
v-if="$slots.append"
data-test-id="card-append"
:class="[$style.append, 'n8n-card-append']"
>
<slot name="append" />
</div>
</div>
@ -45,7 +49,7 @@ const classes = computed(() => ({
border-radius: var(--border-radius-large);
border: var(--border-base);
background-color: var(--color-background-xlight);
padding: var(--spacing-s);
padding: var(--card--padding, var(--spacing-s));
display: flex;
flex-direction: row;
width: 100%;
@ -101,5 +105,6 @@ const classes = computed(() => ({
display: flex;
align-items: center;
cursor: default;
width: var(--card--append--width, unset);
}
</style>

View file

@ -31,6 +31,10 @@ const props = defineProps({
multiple: {
type: Boolean,
},
multipleLimit: {
type: Number,
default: 0,
},
filterMethod: {
type: Function,
},
@ -120,6 +124,7 @@ defineExpose({
<ElSelect
v-bind="{ ...$props, ...listeners }"
ref="innerSelect"
:multiple-limit="props.multipleLimit"
:model-value="props.modelValue ?? undefined"
:size="computedSize"
:popper-class="props.popperClass"

View file

@ -75,6 +75,38 @@ describe('useDeviceSupport()', () => {
});
});
describe('isMobileDevice', () => {
it('should be true for iOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for Android user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be false for non-mobile user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(false);
});
it('should be true for iPad user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for iPod user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
});
describe('isCtrlKeyPressed()', () => {
it('should return true for metaKey press on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });

View file

@ -12,12 +12,16 @@ export function useDeviceSupport() {
!window.matchMedia('(any-pointer: fine)').matches,
);
const userAgent = ref(navigator.userAgent.toLowerCase());
const isMacOs = ref(
userAgent.value.includes('macintosh') ||
const isIOs = ref(
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipad') ||
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipod'),
);
const isAndroidOs = ref(userAgent.value.includes('android'));
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
@ -30,7 +34,10 @@ export function useDeviceSupport() {
return {
userAgent: userAgent.value,
isTouchDevice: isTouchDevice.value,
isAndroidOs: isAndroidOs.value,
isIOs: isIOs.value,
isMacOs: isMacOs.value,
isMobileDevice: isMobileDevice.value,
controlKeyCode: controlKeyCode.value,
isCtrlKeyPressed,
};

View file

@ -16,6 +16,7 @@
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%);
--prim-gray-320-alpha-010: hsla(var(--prim-gray-h), 10%, 68%, 0.1);
--prim-gray-200: hsl(var(--prim-gray-h), 18%, 80%);
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);

View file

@ -139,12 +139,20 @@
--color-infobox-examples-border-color: var(--prim-gray-670);
// Code
--color-code-tags-string: var(--prim-color-alt-f-tint-150);
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
--color-code-tags-keyword: var(--prim-color-alt-g-tint-150);
--color-code-tags-operator: var(--prim-color-alt-h);
--color-code-tags-variable: var(--prim-color-primary-tint-100);
--color-code-tags-definition: var(--prim-color-alt-e);
--color-code-tags-string: #9ecbff;
--color-code-tags-regex: #9ecbff;
--color-code-tags-primitive: #79b8ff;
--color-code-tags-keyword: #f97583;
--color-code-tags-variable: #79b8ff;
--color-code-tags-parameter: #e1e4e8;
--color-code-tags-function: #b392f0;
--color-code-tags-constant: #79b8ff;
--color-code-tags-property: #79b8ff;
--color-code-tags-type: #b392f0;
--color-code-tags-class: #b392f0;
--color-code-tags-heading: #79b8ff;
--color-code-tags-invalid: #f97583;
--color-code-tags-comment: #6a737d;
--color-json-default: var(--prim-color-secondary-tint-200);
--color-json-null: var(--color-danger);
--color-json-boolean: var(--prim-color-alt-a);
@ -155,15 +163,18 @@
--color-json-brackets-hover: var(--prim-color-alt-e);
--color-json-line: var(--prim-gray-200);
--color-json-highlight: var(--color-background-base);
--color-code-background: var(--prim-gray-800);
--color-code-background: var(--prim-gray-820);
--color-code-background-readonly: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
--color-code-foreground: var(--prim-gray-70);
--color-code-caret: var(--prim-gray-10);
--color-code-selection: var(--prim-color-alt-e-alpha-04);
--color-code-gutterBackground: var(--prim-gray-670);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-200);
--color-code-selection: #3392ff44;
--color-code-selection-highlight: #17e5e633;
--color-code-gutter-background: var(--prim-gray-820);
--color-code-gutter-foreground: var(--prim-gray-320);
--color-code-gutter-foreground-active: var(--prim-gray-10);
--color-code-indentation-marker: var(--prim-gray-740);
--color-code-indentation-marker-active: var(--prim-gray-670);
--color-line-break: var(--prim-gray-420);
--color-code-line-break: var(--prim-color-secondary-tint-100);

View file

@ -183,12 +183,20 @@
--color-infobox-examples-border-color: var(--color-foreground-light);
// Code
--color-code-tags-string: var(--prim-color-alt-f);
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
--color-code-tags-keyword: var(--prim-color-alt-g);
--color-code-tags-operator: var(--prim-color-alt-h);
--color-code-tags-variable: var(--prim-color-alt-c-shade-100);
--color-code-tags-definition: var(--prim-color-alt-e-shade-150);
--color-code-tags-string: #032f62;
--color-code-tags-regex: #032f62;
--color-code-tags-primitive: #005cc5;
--color-code-tags-keyword: #d73a49;
--color-code-tags-variable: #005cc5;
--color-code-tags-parameter: #24292e;
--color-code-tags-function: #6f42c1;
--color-code-tags-constant: #005cc5;
--color-code-tags-property: #005cc5;
--color-code-tags-type: #005cc5;
--color-code-tags-class: #6f42c1;
--color-code-tags-heading: #005cc5;
--color-code-tags-invalid: #cb2431;
--color-code-tags-comment: #6a737d;
--color-json-default: var(--prim-color-secondary-shade-100);
--color-json-null: var(--prim-color-alt-c);
--color-json-boolean: var(--prim-color-alt-a);
@ -201,13 +209,16 @@
--color-json-highlight: var(--prim-gray-70);
--color-code-background: var(--prim-gray-0);
--color-code-background-readonly: var(--prim-gray-40);
--color-code-lineHighlight: var(--prim-gray-40);
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
--color-code-foreground: var(--prim-gray-670);
--color-code-caret: var(--prim-gray-420);
--color-code-selection: var(--prim-gray-120);
--color-code-gutterBackground: var(--prim-gray-0);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-420);
--color-code-caret: var(--prim-gray-820);
--color-code-selection: #0366d625;
--color-code-selection-highlight: #34d05840;
--color-code-gutter-background: var(--prim-gray-0);
--color-code-gutter-foreground: var(--prim-gray-320);
--color-code-gutter-foreground-active: var(--prim-gray-670);
--color-code-indentation-marker: var(--prim-gray-70);
--color-code-indentation-marker-active: var(--prim-gray-200);
--color-line-break: var(--prim-gray-320);
--color-code-line-break: var(--prim-color-secondary-tint-200);

View file

@ -5,6 +5,7 @@
@use './base.scss';
@use './pagination.scss';
@use './dialog.scss';
@use './display.scss';
// @use "./autocomplete.scss";
@use './dropdown.scss';
@use './dropdown-menu.scss';

View file

@ -0,0 +1,20 @@
@use '../common/var';
@mixin breakpoint($name) {
@if map-has-key(var.$breakpoints-spec, $name) {
$query: map-get(var.$breakpoints-spec, $name);
$media-query: '';
@each $key, $value in $query {
$media-query: '#{$media-query} and (#{$key}: #{$value})';
}
$media-query: unquote(str-slice($media-query, 6)); // Remove the initial ' and '
@media screen and #{$media-query} {
@content;
}
} @else {
@error "No breakpoint named `#{$name}` found in `$breakpoints-spec`.";
}
}

View file

@ -0,0 +1,6 @@
@forward 'breakpoints';
@forward 'button';
@forward 'config';
@forward 'function';
@forward 'mixins';
@forward 'utils';

View file

@ -25,6 +25,7 @@
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language": "^6.10.1",
"@codemirror/lint": "^6.8.0",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.26.3",
"@fontsource/open-sans": "^4.5.0",
@ -39,6 +40,8 @@
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@replit/codemirror-indentation-markers": "^6.5.3",
"@typescript/vfs": "^1.6.0",
"@sentry/vue": "catalog:frontend",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
@ -52,6 +55,7 @@
"change-case": "^5.4.4",
"chart.js": "^4.4.0",
"codemirror-lang-html-n8n": "^1.0.0",
"comlink": "^4.4.1",
"dateformat": "^3.0.3",
"email-providers": "^2.0.1",
"esprima-next": "5.8.4",
@ -70,6 +74,7 @@
"qrcode.vue": "^3.3.4",
"stream-browserify": "^3.0.0",
"timeago.js": "^4.0.2",
"typescript": "^5.5.2",
"uuid": "catalog:",
"v3-infinite-loading": "^1.2.2",
"vue": "catalog:frontend",

View file

@ -192,6 +192,8 @@ watch(defaultLocale, (newLocale) => {
.header {
grid-area: header;
z-index: var(--z-index-app-header);
min-width: 0;
min-height: 0;
}
.sidebar {

View file

@ -1,5 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { makeRestApiRequest, request } from '@/utils/apiUtils';
export interface TestDefinitionRecord {
id: string;
name: string;
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
description?: string | null;
updatedAt?: string;
createdAt?: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string }>;
}
interface CreateTestDefinitionParams {
name: string;
workflowId: string;
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
mockedNodes?: Array<{ name: string }>;
}
export interface UpdateTestResponse {
createdAt: string;
updatedAt: string;
id: string;
name: string;
workflowId: string;
description: string | null;
annotationTag: string | null;
evaluationWorkflowId: string | null;
annotationTagId: string | null;
description?: string | null;
annotationTag?: string | null;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
}
export interface TestRunRecord {
id: string;
testDefinitionId: string;
status: 'new' | 'running' | 'completed' | 'error';
metrics?: Record<string, number>;
createdAt: string;
updatedAt: string;
runAt: string;
completedAt: string;
}
interface GetTestRunParams {
testDefinitionId: string;
runId: string;
}
interface DeleteTestRunParams {
testDefinitionId: string;
runId: string;
}
const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
export async function getTestDefinitions(context: IRestApiContext) {
export async function getTestDefinitions(
context: IRestApiContext,
params?: { workflowId?: string },
) {
let url = endpoint;
if (params?.workflowId) {
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
}
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
context,
'GET',
endpoint,
url,
);
}
export async function getTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
}
export async function createTestDefinition(
@ -71,3 +107,125 @@ export async function updateTestDefinition(
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
}
// Metrics
export interface TestMetricRecord {
id: string;
name: string;
testDefinitionId: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateTestMetricParams {
testDefinitionId: string;
name: string;
}
export interface UpdateTestMetricParams {
name: string;
id: string;
testDefinitionId: string;
}
export interface DeleteTestMetricParams {
testDefinitionId: string;
id: string;
}
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestMetricRecord[]>(
context,
'GET',
getMetricsEndpoint(testDefinitionId),
);
};
export const getTestMetric = async (
context: IRestApiContext,
testDefinitionId: string,
id: string,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'GET',
getMetricsEndpoint(testDefinitionId, id),
);
};
export const createTestMetric = async (
context: IRestApiContext,
params: CreateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'POST',
getMetricsEndpoint(params.testDefinitionId),
{ name: params.name },
);
};
export const updateTestMetric = async (
context: IRestApiContext,
params: UpdateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'PATCH',
getMetricsEndpoint(params.testDefinitionId, params.id),
{ name: params.name },
);
};
export const deleteTestMetric = async (
context: IRestApiContext,
params: DeleteTestMetricParams,
) => {
return await makeRestApiRequest(
context,
'DELETE',
getMetricsEndpoint(params.testDefinitionId, params.id),
);
};
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
// Get all test runs for a test definition
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestRunRecord[]>(
context,
'GET',
getRunsEndpoint(testDefinitionId),
);
};
// Get specific test run
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
return await makeRestApiRequest<TestRunRecord>(
context,
'GET',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};
// Start a new test run
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/run`,
headers: { 'push-ref': context.pushRef },
});
// CLI is returning the response without wrapping it in `data` key
return response as { success: boolean };
};
// Delete a test run
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
return await makeRestApiRequest<{ success: boolean }>(
context,
'DELETE',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};

View file

@ -1,32 +1,24 @@
<script setup lang="ts">
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import type { LanguageSupport } from '@codemirror/language';
import type { Extension, Line } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { EditorView } from '@codemirror/view';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useRootStore } from '@/stores/root.store';
import { useCodeEditor } from '@/composables/useCodeEditor';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import AskAI from './AskAI/AskAI.vue';
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
import { useCompleter } from './completer';
import { CODE_PLACEHOLDERS } from './constants';
import { useLinter } from './linter';
import { codeNodeEditorTheme } from './theme';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { useSettingsStore } from '@/stores/settings.store';
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
mode: CodeExecutionMode;
@ -36,6 +28,7 @@ type Props = {
language?: CodeNodeEditorLanguage;
isReadOnly?: boolean;
rows?: number;
id?: string;
};
const props = withDefaults(defineProps<Props>(), {
@ -44,99 +37,57 @@ const props = withDefaults(defineProps<Props>(), {
language: 'javaScript',
isReadOnly: false,
rows: 4,
id: crypto.randomUUID(),
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const message = useMessage();
const editor = ref(null) as Ref<EditorView | null>;
const languageCompartment = ref(new Compartment());
const dragAndDropCompartment = ref(new Compartment());
const linterCompartment = ref(new Compartment());
const isEditorHovered = ref(false);
const isEditorFocused = ref(false);
const tabs = ref(['code', 'ask-ai']);
const activeTab = ref('code');
const hasChanges = ref(false);
const isLoadingAIResponse = ref(false);
const codeNodeEditorRef = ref<HTMLDivElement>();
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
const { createLinter } = useLinter(() => props.mode, editor);
const hasManualChanges = ref(false);
const rootStore = useRootStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const linter = useLinter(
() => props.mode,
() => props.language,
);
const extensions = computed(() => [linter.value]);
const placeholder = computed(() => CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '');
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly;
});
const { highlightLine, readEditorValue, editor } = useCodeEditor({
id: props.id,
editorRef: codeNodeEditorRef,
language: () => props.language,
languageParams: () => ({ mode: props.mode }),
editorValue: () => props.modelValue,
placeholder,
extensions,
isReadOnly: () => props.isReadOnly,
theme: {
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
},
onChange: onEditorUpdate,
});
onMounted(() => {
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
const { isReadOnly, language } = props;
const extensions: Extension[] = [
...readOnlyEditorExtensions,
EditorState.readOnly.of(isReadOnly),
EditorView.editable.of(!isReadOnly),
codeNodeEditorTheme({
isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
}),
];
if (!isReadOnly) {
const linter = createLinter(language);
if (linter) {
extensions.push(linterCompartment.value.of(linter));
}
extensions.push(
...writableEditorExtensions,
dragAndDropCompartment.value.of(dragAndDropExtension.value),
EditorView.domEventHandlers({
focus: () => {
isEditorFocused.value = true;
},
blur: () => {
isEditorFocused.value = false;
},
}),
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged) return;
trackCompletion(viewUpdate);
const value = editor.value?.state.doc.toString();
if (value) {
emit('update:modelValue', value);
}
hasChanges.value = true;
}),
);
}
const [languageSupport, ...otherExtensions] = languageExtensions.value;
extensions.push(languageCompartment.value.of(languageSupport), ...otherExtensions);
const state = EditorState.create({
doc: props.modelValue ?? placeholder.value,
extensions,
});
editor.value = new EditorView({
parent: codeNodeEditorRef.value,
state,
});
// empty on first load, default param value
if (!props.modelValue) {
refreshPlaceholder();
emit('update:modelValue', placeholder.value);
}
});
@ -150,89 +101,12 @@ const askAiEnabled = computed(() => {
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
});
const placeholder = computed(() => {
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
});
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
});
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
// eslint-disable-next-line vue/return-in-computed-property
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
switch (props.language) {
case 'javaScript':
return [javascript(), autocompletionExtension('javaScript')];
case 'python':
return [python(), autocompletionExtension('python')];
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
emit('update:modelValue', placeholder.value);
}
});
watch(
() => props.modelValue,
(newValue) => {
if (!editor.value) {
return;
}
const current = editor.value.state.doc.toString();
if (current === newValue) {
return;
}
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: newValue },
});
},
);
watch(
() => props.mode,
(_newMode, previousMode: CodeExecutionMode) => {
reloadLinter();
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[props.language]?.[previousMode]) {
refreshPlaceholder();
}
},
);
watch(dragAndDropExtension, (extension) => {
editor.value?.dispatch({
effects: dragAndDropCompartment.value.reconfigure(extension),
});
});
watch(
() => props.language,
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[props.mode]) {
refreshPlaceholder();
}
const [languageSupport] = languageExtensions.value;
editor.value?.dispatch({
effects: languageCompartment.value.reconfigure(languageSupport),
});
reloadLinter();
},
);
watch(
askAiEnabled,
async (isEnabled) => {
if (isEnabled && !props.modelValue) {
emit('update:modelValue', placeholder.value);
}
await nextTick();
hasChanges.value = props.modelValue !== placeholder.value;
},
{ immediate: true },
);
function getCurrentEditorContent() {
return editor.value?.state.doc.toString() ?? '';
}
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
// Confirm dialog if leaving ask-ai tab during loading
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
@ -243,69 +117,28 @@ async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
showCancelButton: true,
});
if (confirmModal === 'confirm') {
return true;
}
return false;
return confirmModal === 'confirm';
}
return true;
}
async function onReplaceCode(code: string) {
async function onAiReplaceCode(code: string) {
const formattedCode = await format(code, {
parser: 'babel',
plugins: [jsParser, estree],
});
editor.value?.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
});
emit('update:modelValue', formattedCode);
activeTab.value = 'code';
hasChanges.value = false;
hasManualChanges.value = false;
}
function onMouseOver(event: MouseEvent) {
const fromElement = event.relatedTarget as HTMLElement;
const containerRef = codeNodeEditorContainerRef.value;
if (!containerRef?.contains(fromElement)) isEditorHovered.value = true;
}
function onMouseOut(event: MouseEvent) {
const fromElement = event.relatedTarget as HTMLElement;
const containerRef = codeNodeEditorContainerRef.value;
if (!containerRef?.contains(fromElement)) isEditorHovered.value = false;
}
function reloadLinter() {
if (!editor.value) return;
const linter = createLinter(props.language);
if (linter) {
editor.value.dispatch({
effects: linterCompartment.value.reconfigure(linter),
});
}
}
function refreshPlaceholder() {
if (!editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: placeholder.value },
});
}
function getLine(lineNumber: number): Line | null {
try {
return editor.value?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
function onEditorUpdate(viewUpdate: ViewUpdate) {
trackCompletion(viewUpdate);
hasManualChanges.value = true;
emit('update:modelValue', readEditorValue());
}
function diffApplied() {
@ -315,25 +148,6 @@ function diffApplied() {
});
}
function highlightLine(lineNumber: number | 'final') {
if (!editor.value) return;
if (lineNumber === 'final') {
editor.value.dispatch({
selection: { anchor: (props.modelValue ?? getCurrentEditorContent()).length },
});
return;
}
const line = getLine(lineNumber);
if (!line) return;
editor.value.dispatch({
selection: { anchor: line.from },
});
}
function trackCompletion(viewUpdate: ViewUpdate) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
@ -342,7 +156,7 @@ function trackCompletion(viewUpdate: ViewUpdate) {
try {
// @ts-expect-error - undocumented fields
const { fromA, toB } = viewUpdate?.changedRanges[0];
const full = getCurrentEditorContent().slice(fromA, toB);
const full = viewUpdate.state.doc.slice(fromA, toB).toString();
const lastDotIndex = full.lastIndexOf('.');
let context = null;
@ -379,16 +193,19 @@ function onAiLoadEnd() {
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInCodeEditor(toRaw(editor.value), event, value);
const valueToInsert =
props.mode === 'runOnceForAllItems'
? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()')
: value;
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
}
</script>
<template>
<div
ref="codeNodeEditorContainerRef"
:class="['code-node-editor', $style['code-node-editor-container'], language]"
@mouseover="onMouseOver"
@mouseout="onMouseOut"
:class="['code-node-editor', $style['code-node-editor-container']]"
>
<el-tabs
v-if="askAiEnabled"
@ -433,8 +250,8 @@ async function onDrop(value: string, event: MouseEvent) {
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
<AskAI
:key="activeTab"
:has-changes="hasChanges"
@replace-code="onReplaceCode"
:has-changes="hasManualChanges"
@replace-code="onAiReplaceCode"
@started-loading="onAiLoadStart"
@finished-loading="onAiLoadEnd"
/>

View file

@ -1,49 +0,0 @@
import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { history, toggleComment, deleteCharBackward } from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import { type Extension, Prec } from '@codemirror/state';
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
export const readOnlyEditorExtensions: readonly Extension[] = [
lineNumbers(),
EditorView.lineWrapping,
highlightSpecialChars(),
];
export const writableEditorExtensions: readonly Extension[] = [
history(),
lintGutter(),
foldGutter(),
codeInputHandler(),
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
highlightActiveLineGutter(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...autocompleteKeyMap,
...historyKeyMap,
{ key: 'Mod-/', run: toggleComment },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
]),
),
];

View file

@ -28,7 +28,7 @@ export const AUTOCOMPLETABLE_BUILT_IN_MODULES_JS = [
export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';
export const DEFAULT_LINTER_DELAY_IN_MS = 300;
export const DEFAULT_LINTER_DELAY_IN_MS = 500;
/**
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.

View file

@ -1,10 +1,10 @@
import type { Diagnostic } from '@codemirror/lint';
import { linter } from '@codemirror/lint';
import { linter as codeMirrorLinter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import * as esprima from 'esprima-next';
import type { Node, MemberExpression } from 'estree';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { toValue, type MaybeRefOrGetter } from 'vue';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useI18n } from '@/composables/useI18n';
import {
@ -17,17 +17,17 @@ import { walk } from './utils';
export const useLinter = (
mode: MaybeRefOrGetter<CodeExecutionMode>,
editor: MaybeRefOrGetter<EditorView | null>,
language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
) => {
const i18n = useI18n();
function createLinter(language: CodeNodeEditorLanguage) {
switch (language) {
const linter = computed(() => {
switch (toValue(language)) {
case 'javaScript':
return linter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
}
return undefined;
}
return [];
});
function lintSource(editorView: EditorView): Diagnostic[] {
const doc = editorView.state.doc.toString();
@ -38,34 +38,7 @@ export const useLinter = (
try {
ast = esprima.parseScript(script, { range: true });
} catch (syntaxError) {
let line;
try {
const lineAtError = editorView.state.doc.line(syntaxError.lineNumber - 1).text;
// optional chaining operators currently unsupported by esprima-next
if (['?.', ']?'].some((operator) => lineAtError.includes(operator))) return [];
} catch {
return [];
}
try {
line = editorView.state.doc.line(syntaxError.lineNumber);
return [
{
from: line.from,
to: line.to,
severity: DEFAULT_LINTER_SEVERITY,
message: i18n.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
},
];
} catch {
/**
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
*/
return [];
}
return [];
}
if (ast === null) return [];
@ -118,7 +91,7 @@ export const useLinter = (
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
const [start, end] = getRange(node);
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@ -250,7 +223,7 @@ export const useLinter = (
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
const [start, end] = getRange(node.property);
const method = getText(node.property);
const method = getText(editorView, node.property);
if (!method) return;
@ -444,7 +417,7 @@ export const useLinter = (
if (shadowStart && start > shadowStart) return; // skip shadow item
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@ -489,7 +462,7 @@ export const useLinter = (
!['json', 'binary'].includes(node.property.name);
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@ -636,19 +609,15 @@ export const useLinter = (
// helpers
// ----------------------------------
function getText(node: RangeNode) {
const editorValue = toValue(editor);
if (!editorValue) return null;
function getText(editorView: EditorView, node: RangeNode) {
const [start, end] = getRange(node);
return editorValue.state.doc.toString().slice(start, end);
return editorView.state.doc.toString().slice(start, end);
}
function getRange(node: RangeNode) {
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
}
return { createLinter };
return linter;
};

View file

@ -32,16 +32,62 @@ interface ThemeSettings {
maxHeight?: string;
minHeight?: string;
rows?: number;
highlightColors?: 'default' | 'html';
}
export const codeNodeEditorTheme = ({
isReadOnly,
minHeight,
maxHeight,
rows,
highlightColors,
}: ThemeSettings) => [
const codeEditorSyntaxHighlighting = syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
{
tag: [
tags.deleted,
tags.character,
tags.macroName,
tags.definition(tags.name),
tags.definition(tags.variableName),
tags.atom,
tags.bool,
],
color: 'var(--color-code-tags-variable)',
},
{ tag: [tags.name, tags.propertyName], color: 'var(--color-code-tags-property)' },
{
tag: [tags.processingInstruction, tags.string, tags.inserted, tags.special(tags.string)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.function(tags.variableName), tags.labelName],
color: 'var(--color-code-tags-function)',
},
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: 'var(--color-code-tags-constant)',
},
{ tag: [tags.className], color: 'var(--color-code-tags-class)' },
{
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
color: 'var(--color-code-tags-primitive)',
},
{ tag: [tags.typeName], color: 'var(--color-code-tags-type)' },
{ tag: [tags.operator, tags.operatorKeyword], color: 'var(--color-code-tags-keyword)' },
{
tag: [tags.url, tags.escape, tags.regexp, tags.link],
color: 'var(--color-code-tags-keyword)',
},
{ tag: [tags.meta, tags.comment, tags.lineComment], color: 'var(--color-code-tags-comment)' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.link, textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-code-tags-heading)' },
{ tag: tags.invalid, color: 'var(--color-code-tags-invalid)' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{
tag: [tags.derefOperator, tags.special(tags.variableName), tags.variableName, tags.separator],
color: 'var(--color-code-foreground)',
},
]),
);
export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: ThemeSettings) => [
EditorView.theme({
'&': {
'font-size': BASE_STYLING.fontSize,
@ -54,13 +100,17 @@ export const codeNodeEditorTheme = ({
'.cm-content': {
fontFamily: BASE_STYLING.fontFamily,
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
lineHeight: 'var(--font-line-height-xloose)',
paddingTop: 'var(--spacing-2xs)',
paddingBottom: 'var(--spacing-s)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--color-code-selection)',
},
'&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
background: 'var(--color-code-selection)',
},
'&.cm-editor': {
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
borderColor: 'var(--border-color-base)',
@ -75,13 +125,19 @@ export const codeNodeEditorTheme = ({
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-code-lineHighlight)',
},
'.cm-lineNumbers .cm-activeLineGutter': {
color: 'var(--color-code-gutter-foreground-active)',
},
'.cm-gutters': {
backgroundColor: isReadOnly
? 'var(--color-code-background-readonly)'
: 'var(--color-code-gutterBackground)',
color: 'var(--color-code-gutterForeground)',
: 'var(--color-code-gutter-background)',
color: 'var(--color-code-gutter-foreground)',
border: '0',
borderRadius: 'var(--border-radius-base)',
borderRightColor: 'var(--border-color-base)',
},
'.cm-gutterElement': {
padding: 0,
},
'.cm-tooltip': {
maxWidth: BASE_STYLING.tooltip.maxWidth,
@ -92,11 +148,30 @@ export const codeNodeEditorTheme = ({
maxHeight: maxHeight ?? '100%',
...(isReadOnly
? {}
: { minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto' }),
: {
minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto',
}),
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 var(--spacing-5xs) 0 var(--spacing-2xs)',
},
'.cm-gutter,.cm-content': {
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
},
'.cm-foldGutter': {
width: '16px',
},
'.cm-fold-marker': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
opacity: 0,
transition: 'opacity 0.3s ease',
},
'.cm-activeLineGutter .cm-fold-marker, .cm-gutters:hover .cm-fold-marker': {
opacity: 1,
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
color: 'var(--color-primary)',
@ -106,103 +181,81 @@ export const codeNodeEditorTheme = ({
cursor: BASE_STYLING.diagnosticButton.cursor,
},
'.cm-diagnostic-error': {
backgroundColor: 'var(--color-background-base)',
backgroundColor: 'var(--color-infobox-background)',
},
'.cm-diagnosticText': {
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-base)',
},
'.cm-diagnosticDocs': {
fontSize: 'var(--font-size-2xs)',
},
'.cm-foldPlaceholder': {
color: 'var(--color-text-base)',
backgroundColor: 'var(--color-background-base)',
border: 'var(--border-base)',
},
'.cm-selectionMatch': {
background: 'var(--color-code-selection-highlight)',
},
'.cm-selectionMatch-main': {
background: 'var(--color-code-selection-highlight)',
},
'.cm-matchingBracket': {
background: 'var(--color-code-selection)',
},
'.cm-completionMatchedText': {
textDecoration: 'none',
fontWeight: '600',
color: 'var(--color-autocomplete-item-selected)',
},
'.cm-faded > span': {
opacity: 0.6,
},
'.cm-panel.cm-search': {
padding: 'var(--spacing-4xs) var(--spacing-2xs)',
},
'.cm-panels': {
background: 'var(--color-background-light)',
color: 'var(--color-text-base)',
},
'.cm-panels-bottom': {
borderTop: 'var(--border-base)',
},
'.cm-textfield': {
color: 'var(--color-text-dark)',
background: 'var(--color-foreground-xlight)',
borderRadius: 'var(--border-radius-base)',
border: 'var(--border-base)',
fontSize: '90%',
},
'.cm-textfield:focus': {
outline: 'none',
borderColor: 'var(--color-secondary)',
},
'.cm-panel button': {
color: 'var(--color-text-base)',
},
'.cm-panel input[type="checkbox"]': {
border: 'var(--border-base)',
outline: 'none',
},
'.cm-panel input[type="checkbox"]:hover': {
border: 'var(--border-base)',
outline: 'none',
},
'.cm-panel.cm-search label': {
fontSize: '90%',
},
'.cm-button': {
outline: 'none',
border: 'var(--border-base)',
color: 'var(--color-text-dark)',
backgroundColor: 'var(--color-foreground-xlight)',
backgroundImage: 'none',
borderRadius: 'var(--border-radius-base)',
fontSize: '90%',
},
}),
highlightColors === 'html'
? syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: '#c678dd' },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: '#e06c75',
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: '#d19a66',
},
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: '#e06c75',
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: '#56b6c2',
},
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
]),
)
: syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.comment,
color: 'var(--color-code-tags-comment)',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.number, tags.self, tags.bool, tags.null],
color: 'var(--color-code-tags-primitive)',
},
{
tag: tags.keyword,
color: 'var(--color-code-tags-keyword)',
},
{
tag: tags.operator,
color: 'var(--color-code-tags-operator)',
},
{
tag: [
tags.variableName,
tags.propertyName,
tags.attributeName,
tags.regexp,
tags.className,
tags.typeName,
],
color: 'var(--color-code-tags-variable)',
},
{
tag: [
tags.definition(tags.typeName),
tags.definition(tags.propertyName),
tags.function(tags.variableName),
],
color: 'var(--color-code-tags-definition)',
},
]),
),
codeEditorSyntaxHighlighting,
];

View file

@ -162,6 +162,7 @@ function moveResource() {
<template #append>
<div :class="$style.cardActions" @click.stop>
<ProjectCardBadge
:class="$style.cardBadge"
:resource="data"
:resource-type="ResourceType.Credential"
:resource-type-label="resourceTypeLabel"
@ -180,9 +181,10 @@ function moveResource() {
<style lang="scss" module>
.cardLink {
--card--padding: 0 0 0 var(--spacing-s);
transition: box-shadow 0.3s ease;
cursor: pointer;
padding: 0 0 0 var(--spacing-s);
align-items: stretch;
&:hover {
@ -215,4 +217,22 @@ function moveResource() {
padding: 0 var(--spacing-s) 0 0;
cursor: default;
}
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing-s) var(--spacing-s);
--card--append--width: 100%;
flex-wrap: wrap;
}
.cardActions {
width: 100%;
padding: 0;
}
.cardBadge {
margin-right: auto;
}
}
</style>

View file

@ -1106,7 +1106,7 @@ function resetCredentialData(): void {
</template>
<template #content>
<div :class="$style.container" data-test-id="credential-edit-dialog">
<div :class="$style.sidebar">
<div v-if="!isEditingManagedCredential" :class="$style.sidebar">
<n8n-menu
mode="tabs"
:items="sidebarItems"

View file

@ -3,6 +3,8 @@ import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
import { createTestingPinia } from '@pinia/testing';
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
import { useCredentialsStore } from '@/stores/credentials.store';
import type { ICredentialsResponse } from '@/Interface';
vi.mock('@/permissions', () => ({
getResourcePermissions: vi.fn(() => ({
@ -23,6 +25,10 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
},
[STORES.SETTINGS]: {
settings: {
enterprise: {
sharing: true,
externalSecrets: false,
},
templates: {
host: '',
},
@ -67,4 +73,54 @@ describe('CredentialEdit', () => {
});
await retry(() => expect(queryByTestId('credential-save-button')).not.toBeInTheDocument());
});
test('hides menu item when credential is managed', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.state.credentials = {
'123': {
isManaged: false,
} as ICredentialsResponse,
};
const { queryByText } = renderComponent({
props: {
activeId: '123', // credentialId will be set to this value in edit mode
isTesting: false,
isSaving: false,
hasUnsavedChanges: false,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit',
},
});
await retry(() => expect(queryByText('Details')).toBeInTheDocument());
await retry(() => expect(queryByText('Connection')).toBeInTheDocument());
await retry(() => expect(queryByText('Sharing')).toBeInTheDocument());
});
test('shows menu item when credential is not managed', async () => {
const credentialsStore = useCredentialsStore();
credentialsStore.state.credentials = {
'123': {
isManaged: true,
} as ICredentialsResponse,
};
const { queryByText } = renderComponent({
props: {
activeId: '123', // credentialId will be set to this value in edit mode
isTesting: false,
isSaving: false,
hasUnsavedChanges: false,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit',
},
});
await retry(() => expect(queryByText('Details')).not.toBeInTheDocument());
await retry(() => expect(queryByText('Connection')).not.toBeInTheDocument());
await retry(() => expect(queryByText('Sharing')).not.toBeInTheDocument());
});
});

View file

@ -245,7 +245,6 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
margin-bottom: 0;
:global(.el-dialog__body) {
background-color: var(--color-expression-editor-modal-background);
height: 100%;
padding: var(--spacing-s);
}

View file

@ -7,20 +7,14 @@ import { computed, onMounted, ref, watch } from 'vue';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { forceParse } from '@/utils/forceParse';
import { completionStatus } from '@codemirror/autocomplete';
import { inputTheme } from './theme';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
type Props = {
modelValue: string;
@ -41,24 +35,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>();
const extensions = computed(() => [
inputTheme(props.isReadOnly),
Prec.highest(
keymap.of([
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
emit('close');
}
return false;
},
},
]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nLang(),
n8nAutocompletion(),
mappingDropCursor(),
@ -66,7 +43,7 @@ const extensions = computed(() => [
history(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
infoBoxTooltips(),
]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));

Some files were not shown because too many files have changed in this diff Show more