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

View file

@ -46,7 +46,14 @@ export function getNodes() {
} }
export function getNodeByName(name: string) { 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) { export function disableNode(name: string) {

View file

@ -1,15 +1,17 @@
import planData from '../fixtures/Plan_data_opt_in_trial.json'; import planData from '../fixtures/Plan_data_opt_in_trial.json';
import { import {
BannerStack,
MainSidebar, MainSidebar,
WorkflowPage, WorkflowPage,
visitPublicApiPage, visitPublicApiPage,
getPublicApiUpgradeCTA, getPublicApiUpgradeCTA,
WorkflowsPage,
} from '../pages'; } from '../pages';
const NUMBER_OF_AI_CREDITS = 100;
const mainSidebar = new MainSidebar(); const mainSidebar = new MainSidebar();
const bannerStack = new BannerStack();
const workflowPage = new WorkflowPage(); const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
describe('Cloud', () => { describe('Cloud', () => {
before(() => { before(() => {
@ -22,6 +24,10 @@ describe('Cloud', () => {
cy.overrideSettings({ cy.overrideSettings({
deployment: { type: 'cloud' }, deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' }, 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/admin/cloud-plan', planData).as('getPlanData');
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo'); 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', () => { it('should render trial banner for opt-in cloud user', () => {
visitWorkflowPage(); visitWorkflowPage();
bannerStack.getters.banner().should('be.visible'); cy.getByTestId('banner-stack').should('be.visible');
mainSidebar.actions.signout(); 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'); 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 { import {
CODE_NODE_NAME, CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME, IF_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME,
} from '../constants'; } from '../constants';
import { import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
WorkflowExecutionsTab,
WorkflowPage as WorkflowPageClass,
WorkflowHistoryPage,
} from '../pages';
const workflowPage = new WorkflowPageClass(); const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab(); const executionsTab = new WorkflowExecutionsTab();
const workflowHistoryPage = new WorkflowHistoryPage();
const createNewWorkflowAndActivate = () => { const createNewWorkflowAndActivate = () => {
workflowPage.actions.visit(); workflowPage.actions.visit();
@ -92,7 +88,7 @@ const switchBetweenEditorAndHistory = () => {
cy.wait(['@getVersion']); cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet'); cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click(); getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']); cy.wait(['@workflowGet']);
cy.wait(1000); cy.wait(1000);
@ -168,7 +164,7 @@ describe('Editor actions should work', () => {
cy.wait(['@getVersion']); cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet'); cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click(); getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']); cy.wait(['@workflowGet']);
cy.wait(1000); 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 { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
import TestTemplate1 from '../fixtures/Test_Template_1.json'; import TestTemplate1 from '../fixtures/Test_Template_1.json';
import TestTemplate2 from '../fixtures/Test_Template_2.json'; import TestTemplate2 from '../fixtures/Test_Template_2.json';
import { clearNotifications } from '../pages/notifications';
import { import {
clickUseWorkflowButtonByTitle, clickUseWorkflowButtonByTitle,
visitTemplateCollectionPage, visitTemplateCollectionPage,
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)'); templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram'); templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup(); templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3); workflowPage.getters.canvasNodes().should('have.length', 3);
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Focus the canvas so the copy to clipboard works // Focus the canvas so the copy to clipboard works
workflowPage.getters.canvasNodes().eq(0).realClick(); workflowPage.getters.canvasNodes().eq(0).realClick();
workflowPage.actions.hitSelectAll(); workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy(); workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard // Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => { cy.readClipboard().then((workflowJSON) => {
const workflow = JSON.parse(workflowJSON); const workflow = JSON.parse(workflowJSON);
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)'); templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud'); templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup(); templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3); workflowPage.getters.canvasNodes().should('have.length', 3);
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id); templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify'); templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup(); templateCredentialsSetupPage.finishCredentialSetup();
getSetupWorkflowCredentialsButton().should('be.visible'); getSetupWorkflowCredentialsButton().should('be.visible');
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)'); templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram'); templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
setupCredsModal.closeModalFromContinueButton(); setupCredsModal.closeModalFromContinueButton();
setupCredsModal.getWorkflowCredentialsModal().should('not.exist'); setupCredsModal.getWorkflowCredentialsModal().should('not.exist');

View file

@ -1,22 +1,20 @@
import { SettingsPage } from '../pages/settings'; const url = '/settings';
const settingsPage = new SettingsPage();
describe('Admin user', { disableAutoLogin: true }, () => { describe('Admin user', { disableAutoLogin: true }, () => {
it('should see same Settings sub menu items as instance owner', () => { it('should see same Settings sub menu items as instance owner', () => {
cy.signinAsOwner(); cy.signinAsOwner();
cy.visit(settingsPage.url); cy.visit(url);
let ownerMenuItems = 0; let ownerMenuItems = 0;
settingsPage.getters.menuItems().then(($el) => { cy.getByTestId('menu-item').then(($el) => {
ownerMenuItems = $el.length; ownerMenuItems = $el.length;
}); });
cy.signout(); cy.signout();
cy.signinAsAdmin(); 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 = [ const actions = [
'Get ranked documents from vector store', 'Get ranked documents from vector store',
'Add documents to vector store', 'Add documents to vector store',
'Retrieve documents for AI processing', 'Retrieve documents for Chain/Tool as Vector Store',
]; ];
nodeCreatorFeature.actions.openNodeCreator(); nodeCreatorFeature.actions.openNodeCreator();

View file

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

View file

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

View file

@ -19,7 +19,6 @@
"value": {}, "value": {},
"matchingColumns": [], "matchingColumns": [],
"schema": [], "schema": [],
"ignoreTypeMismatchErrors": false,
"attemptToConvertTypes": false, "attemptToConvertTypes": false,
"convertFieldsToString": true "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'; 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 { export class BasePage implements IE2ETestPage {
getters = {}; getters = {};

View file

@ -1,5 +1,13 @@
import { BasePage } from './base'; 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 { export class CredentialsPage extends BasePage {
url = '/home/credentials'; url = '/home/credentials';

View file

@ -8,6 +8,14 @@ const AI_ASSISTANT_FEATURE = {
disabledFor: 'control', 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 { export class AIAssistant extends BasePage {
url = '/workflows/new'; url = '/workflows/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base'; 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 { export class NodeCreator extends BasePage {
url = '/workflow/new'; url = '/workflow/new';

View file

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

View file

@ -3,6 +3,14 @@ import { SigninPage } from './signin';
import { WorkflowsPage } from './workflows'; import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants'; 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 { export class MfaLoginPage extends BasePage {
url = '/mfa'; url = '/mfa';

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base'; 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 { export class ChangePasswordModal extends BasePage {
getters = { getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(), modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -2,6 +2,14 @@ import { getCredentialSaveButton, saveCredential } from '../../composables/modal
import { getVisibleSelect } from '../../utils'; import { getVisibleSelect } from '../../utils';
import { BasePage } from '../base'; 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 { export class CredentialsModal extends BasePage {
getters = { getters = {
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }), newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
@ -61,6 +69,7 @@ export class CredentialsModal extends BasePage {
this.getters this.getters
.credentialInputs() .credentialInputs()
.find('input[type=text], input[type=password]') .find('input[type=text], input[type=password]')
.filter(':not([readonly])')
.each(($el) => { .each(($el) => {
cy.wrap($el).type('test'); cy.wrap($el).type('test');
}); });

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base'; 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 { export class MessageBox extends BasePage {
getters = { getters = {
modal: () => cy.get('.el-message-box', { withinSubject: null }), modal: () => cy.get('.el-message-box', { withinSubject: null }),

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base'; 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 { export class MfaSetupModal extends BasePage {
getters = { getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(), modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base'; 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 { export class WorkflowSharingModal extends BasePage {
getters = { getters = {
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }), modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),

View file

@ -1,6 +1,14 @@
import { BasePage } from './base'; import { BasePage } from './base';
import { getVisiblePopper, getVisibleSelect } from '../utils'; 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 { export class NDV extends BasePage {
getters = { getters = {
container: () => cy.getByTestId('ndv'), container: () => cy.getByTestId('ndv'),

View file

@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
* Actions * Actions
*/ */
export const clearNotifications = () => { 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 { BasePage } from './base';
import { getVisibleSelect } from '../utils'; 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 { export class SettingsLogStreamingPage extends BasePage {
url = '/settings/log-streaming'; url = '/settings/log-streaming';

View file

@ -7,6 +7,14 @@ import { MfaSetupModal } from './modals/mfa-setup-modal';
const changePasswordModal = new ChangePasswordModal(); const changePasswordModal = new ChangePasswordModal();
const mfaSetupModal = new MfaSetupModal(); 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 { export class PersonalSettingsPage extends BasePage {
url = '/settings/personal'; 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 mainSidebar = new MainSidebar();
const settingsSidebar = new SettingsSidebar(); 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 { export class SettingsUsersPage extends BasePage {
url = '/settings/users'; 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 { BasePage } from '../base';
import { WorkflowsPage } from '../workflows'; 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 { export class MainSidebar extends BasePage {
getters = { getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base'; 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 { export class SettingsSidebar extends BasePage {
getters = { getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import { WorkflowsPage } from './workflows'; import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants'; 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 { export class SigninPage extends BasePage {
url = '/signin'; url = '/signin';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base'; 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 { export class TemplatesPage extends BasePage {
url = '/templates'; url = '/templates';

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import Chainable = Cypress.Chainable; 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 { export class VariablesPage extends BasePage {
url = '/variables'; url = '/variables';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base'; 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 { export class WorkerViewPage extends BasePage {
url = '/settings/workers'; url = '/settings/workers';

View file

@ -3,6 +3,14 @@ import { WorkflowPage } from './workflow';
const workflowPage = new WorkflowPage(); 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 { export class WorkflowExecutionsTab extends BasePage {
getters = { getters = {
executionsTabButton: () => cy.getByTestId('radio-button-executions'), 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'; import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
const nodeCreator = new NodeCreator(); 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 { export class WorkflowPage extends BasePage {
url = '/workflow/new'; url = '/workflow/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base'; 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 { export class WorkflowsPage extends BasePage {
url = '/home/workflows'; url = '/home/workflows';

View file

@ -39,13 +39,23 @@ export class TaskRunnersConfig {
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE') @Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
maxOldSpaceSize: string = ''; 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') @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') @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. */ /** 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') @Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')

View file

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

View file

@ -131,6 +131,7 @@ export class LmChatGoogleVertex implements INodeType {
const credentials = await this.getCredentials('googleApi'); const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string); const privateKey = formatPrivateKey(credentials.privateKey as string);
const email = (credentials.email as string).trim(); const email = (credentials.email as string).trim();
const region = credentials.region as string;
const modelName = this.getNodeParameter('modelName', itemIndex) as string; const modelName = this.getNodeParameter('modelName', itemIndex) as string;
@ -165,6 +166,7 @@ export class LmChatGoogleVertex implements INodeType {
private_key: privateKey, private_key: privateKey,
}, },
}, },
location: region,
model: modelName, model: modelName,
topK: options.topK, topK: options.topK,
topP: options.topP, 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 { ZepVectorStore } from '@langchain/community/vectorstores/zep';
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
import type { IDataObject, INodeProperties } from 'n8n-workflow'; import type { IDataObject, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow';
@ -84,17 +84,21 @@ export class VectorStoreZep extends createVectorStoreNode({
const credentials = await context.getCredentials<{ const credentials = await context.getCredentials<{
apiKey?: string; apiKey?: string;
apiUrl: string; apiUrl: string;
cloud: boolean;
}>('zepApi'); }>('zepApi');
const zepConfig: IZepConfig = { const zepConfig = {
apiUrl: credentials.apiUrl,
apiKey: credentials.apiKey, apiKey: credentials.apiKey,
collectionName, collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536, embeddingDimensions: options.embeddingDimensions ?? 1536,
metadata: filter, 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) { async populateVectorStore(context, embeddings, documents, itemIndex) {
const collectionName = context.getNodeParameter('collectionName', itemIndex) as string; const collectionName = context.getNodeParameter('collectionName', itemIndex) as string;
@ -107,10 +111,10 @@ export class VectorStoreZep extends createVectorStoreNode({
const credentials = await context.getCredentials<{ const credentials = await context.getCredentials<{
apiKey?: string; apiKey?: string;
apiUrl: string; apiUrl: string;
cloud: boolean;
}>('zepApi'); }>('zepApi');
const zepConfig = { const zepConfig = {
apiUrl: credentials.apiUrl,
apiKey: credentials.apiKey, apiKey: credentials.apiKey,
collectionName, collectionName,
embeddingDimensions: options.embeddingDimensions ?? 1536, embeddingDimensions: options.embeddingDimensions ?? 1536,
@ -118,7 +122,14 @@ export class VectorStoreZep extends createVectorStoreNode({
}; };
try { 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) { } catch (error) {
const errorCode = (error as IDataObject).code as number; const errorCode = (error as IDataObject).code as number;
const responseData = (error as IDataObject).responseData as string; 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>({ const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
sharedFields: [], sharedFields: [],
insertFields: [], insertFields: [],
loadFields: [], loadFields: [
{
name: 'loadField',
},
],
retrieveFields: [], retrieveFields: [],
updateFields: [], updateFields: [],
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore), getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
@ -82,6 +86,7 @@ describe('createVectorStoreNode', () => {
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped; const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
// ASSERT // ASSERT
expect(nodeType.description).toMatchSnapshot();
expect(wrappedVectorStore).toEqual(vectorStore); expect(wrappedVectorStore).toEqual(vectorStore);
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled(); expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
}); });

View file

@ -80,10 +80,13 @@ export interface VectorStoreNodeConstructorArgs {
) => Promise<VectorStore>; ) => Promise<VectorStore>;
} }
function transformDescriptionForOperationMode(fields: INodeProperties[], mode: NodeOperationMode) { function transformDescriptionForOperationMode(
fields: INodeProperties[],
mode: NodeOperationMode | NodeOperationMode[],
) {
return fields.map((field) => ({ return fields.map((field) => ({
...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', 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', value: 'retrieve',
description: 'Retrieve documents from vector store to be used as vector store with AI nodes', 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, outputConnectionType: NodeConnectionType.AiVectorStore,
}, },
{ {
name: 'Retrieve Documents (As Tool for AI Agent)', name: 'Retrieve Documents (As Tool for AI Agent)',
value: 'retrieve-as-tool', value: 'retrieve-as-tool',
description: 'Retrieve documents from vector store to be used as tool with AI nodes', 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, 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.retrieveFields ?? [], 'retrieve'),
...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'), ...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'),
], ],

View file

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

View file

@ -4,7 +4,19 @@ import type { INodeExecutionData } from 'n8n-workflow';
import { ValidationError } from './errors/validation-error'; import { ValidationError } from './errors/validation-error';
import { isObject } from './obj-utils'; 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) { function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
for (const key in item) { for (const key in item) {

View file

@ -195,4 +195,3 @@ export const WsStatusCodes = {
} as const; } as const;
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits'; 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'; } from '@n8n/api-types';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express'; import { Response } from 'express';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web'; 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 { CredentialsService } from '@/credentials/credentials.service';
import { Body, Post, RestController } from '@/decorators'; import { Body, Post, RestController } from '@/decorators';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';

View file

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

View file

@ -542,7 +542,7 @@ export class CredentialsService {
if (sharing) { if (sharing) {
// Decrypt the data if we found the credential with the `credential:update` // Decrypt the data if we found the credential with the `credential:update`
// scope. // scope.
decryptedData = this.decrypt(sharing.credentials); decryptedData = this.decrypt(sharing.credentials, true);
} else { } else {
// Otherwise try to find them with only the `credential:read` scope. In // Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data. // 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 // Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = { export type MockedNodeItem = {
name: string; name?: string;
id: string;
}; };
/** /**

View file

@ -35,4 +35,23 @@ export class TestRun extends WithTimestampsAndStringId {
@Column(jsonColumnType, { nullable: true }) @Column(jsonColumnType, { nullable: true })
metrics: AggregatedTestRunMetrics; 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 { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -154,4 +155,5 @@ export const mysqlMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640, AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324, AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
]; ];

View file

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

View file

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

View file

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

View file

@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
description: z.string().optional(), description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(), evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: 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(); .strict();

View file

@ -121,13 +121,26 @@ export class TestDefinitionService {
relations: ['workflow'], 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) => { attrs.mockedNodes.forEach((node) => {
if (!existingNodeNames.has(node.name)) { if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(node.name))) {
throw new BadRequestError(`Pinned node not found in the workflow: ${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 // 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' }), 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( const executionDataJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
); );
describe('createPinData', () => { describe('createPinData', () => {
test('should create pin data from past execution data', () => { 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); const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -25,7 +36,7 @@ describe('createPinData', () => {
}); });
test('should not create pin data for non-existing mocked nodes', () => { 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); const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -33,9 +44,17 @@ describe('createPinData', () => {
}); });
test('should create pin data for all mocked nodes', () => { test('should create pin data for all mocked nodes', () => {
const mockedNodes = ['When clicking Test workflow', 'Edit Fields', 'Code'].map((name) => ({ const mockedNodes = [
name, {
})); 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); const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -53,4 +72,33 @@ describe('createPinData', () => {
expect(pinData).toEqual({}); 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 { stringify } from 'flatted';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { mock, mockDeep } from 'jest-mock-extended'; 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 path from 'path';
import type { ActiveExecutions } from '@/active-executions'; 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' }), 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( const wfEvaluationJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
); );
@ -60,6 +67,7 @@ const executionMocks = [
status: 'success', status: 'success',
executionData: { executionData: {
data: stringify(executionDataJson), data: stringify(executionDataJson),
workflowData: wfUnderTestJson,
}, },
}), }),
mock<ExecutionEntity>({ mock<ExecutionEntity>({
@ -68,6 +76,7 @@ const executionMocks = [
status: 'success', status: 'success',
executionData: { executionData: {
data: stringify(executionDataJson), 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>) { function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
return mock<IRun>({ return mock<IRun>({
data: { 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.createTestRun.mockClear();
testRunRepository.markAsRunning.mockClear(); testRunRepository.markAsRunning.mockClear();
testRunRepository.markAsCompleted.mockClear(); testRunRepository.markAsCompleted.mockClear();
testRunRepository.incrementFailed.mockClear();
testRunRepository.incrementPassed.mockClear();
}); });
test('should create an instance of TestRunnerService', async () => { test('should create an instance of TestRunnerService', async () => {
@ -159,6 +183,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
expect(testRunnerService).toBeInstanceOf(TestRunnerService); expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -173,6 +198,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -210,6 +236,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -250,7 +277,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({ mock<TestDefinition>({
workflowId: 'workflow-under-test-id', workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-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 // Check Test Run status was updated correctly
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1); expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsRunning).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).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', { expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
metric1: 0.75, metric1: 0.75,
metric2: 0, 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 () => { test('should specify correct start nodes when running workflow under test', async () => {
@ -307,6 +507,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -347,7 +548,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({ mock<TestDefinition>({
workflowId: 'workflow-under-test-id', workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-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, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
const startNodesData = (testRunnerService as any).getStartNodesData( const startNodesData = (testRunnerService as any).getStartNodesData(
@ -404,6 +606,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
const startNodesData = (testRunnerService as any).getStartNodesData( const startNodesData = (testRunnerService as any).getStartNodesData(

View file

@ -1,13 +1,15 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { parse } from 'flatted'; import { parse } from 'flatted';
import { ErrorReporter } from 'n8n-core';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
IRun, IRun,
IRunData, IRunData,
IRunExecutionData, IRunExecutionData,
IWorkflowBase,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import assert from 'node:assert'; import assert from 'node:assert';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
@ -44,6 +46,7 @@ export class TestRunnerService {
private readonly testRunRepository: TestRunRepository, private readonly testRunRepository: TestRunRepository,
private readonly testMetricRepository: TestMetricRepository, private readonly testMetricRepository: TestMetricRepository,
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly errorReporter: ErrorReporter,
) {} ) {}
/** /**
@ -94,11 +97,17 @@ export class TestRunnerService {
private async runTestCase( private async runTestCase(
workflow: WorkflowEntity, workflow: WorkflowEntity,
pastExecutionData: IRunExecutionData, pastExecutionData: IRunExecutionData,
pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[], mockedNodes: MockedNodeItem[],
userId: string, userId: string,
): Promise<IRun | undefined> { ): Promise<IRun | undefined> {
// Create pin data from the past execution data // 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 // Prepare the data to run the workflow
const data: IWorkflowExecutionDataProcess = { const data: IWorkflowExecutionDataProcess = {
@ -127,6 +136,7 @@ export class TestRunnerService {
evaluationWorkflow: WorkflowEntity, evaluationWorkflow: WorkflowEntity,
expectedData: IRunData, expectedData: IRunData,
actualData: IRunData, actualData: IRunData,
testRunId?: string,
) { ) {
// Prepare the evaluation wf input data. // Prepare the evaluation wf input data.
// Provide both the expected data and the actual 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 // Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]); 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'; data.executionMode = 'evaluation';
// Trigger the evaluation workflow // Trigger the evaluation workflow
@ -216,51 +232,66 @@ export class TestRunnerService {
// 2. Run over all the test cases // 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 // Object to collect the results of the evaluation workflow executions
const metrics = new EvaluationMetrics(testMetricNames); const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) { for (const { id: pastExecutionId } of pastExecutions) {
// Fetch past execution with data try {
const pastExecution = await this.executionRepository.findOne({ // Fetch past execution with data
where: { id: pastExecutionId }, const pastExecution = await this.executionRepository.findOne({
relations: ['executionData', 'metadata'], where: { id: pastExecutionId },
}); relations: ['executionData', 'metadata'],
assert(pastExecution, 'Execution not found'); });
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 // Run the test case and wait for it to finish
const testCaseExecution = await this.runTestCase( const testCaseExecution = await this.runTestCase(
workflow, workflow,
executionData, executionData,
test.mockedNodes, pastExecution.executionData.workflowData,
user.id, test.mockedNodes,
); user.id,
);
// In case of a permission check issue, the test case execution will be undefined. // In case of a permission check issue, the test case execution will be undefined.
// Skip them and continue with the next test case // Skip them, increment the failed count and continue with the next test case
if (!testCaseExecution) { if (!testCaseExecution) {
continue; 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(); 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 { MockedNodeItem } from '@/databases/entities/test-definition.ee';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -13,16 +14,33 @@ export function createPinData(
workflow: WorkflowEntity, workflow: WorkflowEntity,
mockedNodes: MockedNodeItem[], mockedNodes: MockedNodeItem[],
executionData: IRunExecutionData, executionData: IRunExecutionData,
pastWorkflowData?: IWorkflowBase,
) { ) {
const pinData = {} as IPinData; 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) { for (const mockedNode of mockedNodes) {
if (workflowNodeNames.has(mockedNode.name)) { assert(mockedNode.id, 'Mocked node ID is missing');
const nodeData = executionData.resultData.runData[mockedNode.name];
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]) { 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 { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-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 { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => {
const nodeTypes = mock<NodeTypes>(); const nodeTypes = mock<NodeTypes>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>(); const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const projectRelationRepository = mock<ProjectRelationRepository>(); const projectRelationRepository = mock<ProjectRelationRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const eventService = new EventService(); const eventService = new EventService();
let telemetryEventRelay: TelemetryEventRelay; let telemetryEventRelay: TelemetryEventRelay;
@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes, nodeTypes,
sharedWorkflowRepository, sharedWorkflowRepository,
projectRelationRepository, projectRelationRepository,
credentialsRepository,
); );
await telemetryEventRelay.init(); await telemetryEventRelay.init();
@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes, nodeTypes,
sharedWorkflowRepository, sharedWorkflowRepository,
projectRelationRepository, projectRelationRepository,
credentialsRepository,
); );
// @ts-expect-error Private method // @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners'); const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes, nodeTypes,
sharedWorkflowRepository, sharedWorkflowRepository,
projectRelationRepository, projectRelationRepository,
credentialsRepository,
); );
// @ts-expect-error Private method // @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners'); const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => {
it('should call telemetry.track when manual node execution finished', async () => { it('should call telemetry.track when manual node execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor'); sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
);
const runData = { const runData = {
status: 'error', status: 'error',
@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => {
error_node_id: '1', error_node_id: '1',
node_id: '1', node_id: '1',
node_type: 'n8n-nodes-base.jira', node_type: 'n8n-nodes-base.jira',
is_managed: false,
credential_type: null,
node_graph_string: JSON.stringify(nodeGraph.nodeGraph), 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 config from '@/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay {
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRelationRepository: ProjectRelationRepository, private readonly projectRelationRepository: ProjectRelationRepository,
private readonly credentialsRepository: CredentialsRepository,
) { ) {
super(eventService); super(eventService);
} }
@ -632,6 +634,10 @@ export class TelemetryEventRelay extends EventRelay {
let nodeGraphResult: INodesGraphResult | null = null; let nodeGraphResult: INodesGraphResult | null = null;
if (!telemetryProperties.success && runData?.data.resultData.error) { 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; telemetryProperties.error_message = runData?.data.resultData.error.message;
let errorNodeName = let errorNodeName =
'node' in runData?.data.resultData.error 'node' in runData?.data.resultData.error
@ -693,6 +699,8 @@ export class TelemetryEventRelay extends EventRelay {
error_node_id: telemetryProperties.error_node_id as string, error_node_id: telemetryProperties.error_node_id as string,
webhook_domain: null, webhook_domain: null,
sharing_role: userRole, sharing_role: userRole,
credential_type: null,
is_managed: false,
}; };
if (!manualExecEventProperties.node_graph_string) { if (!manualExecEventProperties.node_graph_string) {
@ -703,7 +711,18 @@ export class TelemetryEventRelay extends EventRelay {
} }
if (runData.data.startData?.destinationNode) { 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, ...manualExecEventProperties,
node_type: TelemetryHelpers.getNodeTypeForName( node_type: TelemetryHelpers.getNodeTypeForName(
workflow, workflow,

View file

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

View file

@ -1,8 +1,9 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { mock } from 'jest-mock-extended'; 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 { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';

View file

@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import config from '@/config'; import config from '@/config';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation'; import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
@ -555,6 +556,22 @@ describe('GET /credentials/:id', () => {
expect(secondCredential.data).toBeDefined(); 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 () => { test('should retrieve non-owned cred for owner', async () => {
const [member1, member2] = await createManyUsers(2, { const [member1, member2] = await createManyUsers(2, {
role: 'global:member', role: 'global:member',

View file

@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
import { Credentials } from 'n8n-core'; import { Credentials } from 'n8n-core';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
@ -1272,6 +1273,23 @@ describe('GET /credentials/:id', () => {
expect(secondResponse.body.data.data).toBeDefined(); 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 () => { test('should retrieve owned cred for member', async () => {
const savedCredential = await saveCredential(randomCredentialPayload(), { const savedCredential = await saveCredential(randomCredentialPayload(), {
user: member, 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({ const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [ mockedNodes: [
{ {
id: 'uuid-1234',
name: 'Schedule Trigger', name: 'Schedule Trigger',
}, },
], ],
}); });
expect(resp.statusCode).toBe(200); 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 () => { 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 testRunRepository = Container.get(TestRunRepository);
const testRun1 = await testRunRepository.createTestRun(testDefinition.id); const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
// Mark as running just to make a slight delay between the runs // 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); const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
// Fetch the first page // Fetch the first page

View file

@ -19,4 +19,26 @@ describe('TaskRunnerServer', () => {
await agent.get('/healthz').expect(200); 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 (!validationResult.valid) {
if (!resourceMapperField.ignoreTypeMismatchErrors) { return { ...validationResult, fieldName: key };
return { ...validationResult, fieldName: key };
} else {
paramValues[key] = resolvedValue;
}
} else { } else {
// If it's valid, set the casted value // If it's valid, set the casted value
paramValues[key] = validationResult.newValue; paramValues[key] = validationResult.newValue;

View file

@ -34,7 +34,11 @@ const classes = computed(() => ({
<slot name="footer" /> <slot name="footer" />
</div> </div>
</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" /> <slot name="append" />
</div> </div>
</div> </div>
@ -45,7 +49,7 @@ const classes = computed(() => ({
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
border: var(--border-base); border: var(--border-base);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
padding: var(--spacing-s); padding: var(--card--padding, var(--spacing-s));
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
@ -101,5 +105,6 @@ const classes = computed(() => ({
display: flex; display: flex;
align-items: center; align-items: center;
cursor: default; cursor: default;
width: var(--card--append--width, unset);
} }
</style> </style>

View file

@ -31,6 +31,10 @@ const props = defineProps({
multiple: { multiple: {
type: Boolean, type: Boolean,
}, },
multipleLimit: {
type: Number,
default: 0,
},
filterMethod: { filterMethod: {
type: Function, type: Function,
}, },
@ -120,6 +124,7 @@ defineExpose({
<ElSelect <ElSelect
v-bind="{ ...$props, ...listeners }" v-bind="{ ...$props, ...listeners }"
ref="innerSelect" ref="innerSelect"
:multiple-limit="props.multipleLimit"
:model-value="props.modelValue ?? undefined" :model-value="props.modelValue ?? undefined"
:size="computedSize" :size="computedSize"
:popper-class="props.popperClass" :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()', () => { describe('isCtrlKeyPressed()', () => {
it('should return true for metaKey press on macOS', () => { it('should return true for metaKey press on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' }); Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });

View file

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

View file

@ -16,6 +16,7 @@
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%); --prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%); --prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%); --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-200: hsl(var(--prim-gray-h), 18%, 80%);
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%); --prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%); --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); --color-infobox-examples-border-color: var(--prim-gray-670);
// Code // Code
--color-code-tags-string: var(--prim-color-alt-f-tint-150); --color-code-tags-string: #9ecbff;
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100); --color-code-tags-regex: #9ecbff;
--color-code-tags-keyword: var(--prim-color-alt-g-tint-150); --color-code-tags-primitive: #79b8ff;
--color-code-tags-operator: var(--prim-color-alt-h); --color-code-tags-keyword: #f97583;
--color-code-tags-variable: var(--prim-color-primary-tint-100); --color-code-tags-variable: #79b8ff;
--color-code-tags-definition: var(--prim-color-alt-e); --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-default: var(--prim-color-secondary-tint-200);
--color-json-null: var(--color-danger); --color-json-null: var(--color-danger);
--color-json-boolean: var(--prim-color-alt-a); --color-json-boolean: var(--prim-color-alt-a);
@ -155,15 +163,18 @@
--color-json-brackets-hover: var(--prim-color-alt-e); --color-json-brackets-hover: var(--prim-color-alt-e);
--color-json-line: var(--prim-gray-200); --color-json-line: var(--prim-gray-200);
--color-json-highlight: var(--color-background-base); --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-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-foreground: var(--prim-gray-70);
--color-code-caret: var(--prim-gray-10); --color-code-caret: var(--prim-gray-10);
--color-code-selection: var(--prim-color-alt-e-alpha-04); --color-code-selection: #3392ff44;
--color-code-gutterBackground: var(--prim-gray-670); --color-code-selection-highlight: #17e5e633;
--color-code-gutterForeground: var(--prim-gray-320); --color-code-gutter-background: var(--prim-gray-820);
--color-code-tags-comment: var(--prim-gray-200); --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-line-break: var(--prim-gray-420);
--color-code-line-break: var(--prim-color-secondary-tint-100); --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); --color-infobox-examples-border-color: var(--color-foreground-light);
// Code // Code
--color-code-tags-string: var(--prim-color-alt-f); --color-code-tags-string: #032f62;
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100); --color-code-tags-regex: #032f62;
--color-code-tags-keyword: var(--prim-color-alt-g); --color-code-tags-primitive: #005cc5;
--color-code-tags-operator: var(--prim-color-alt-h); --color-code-tags-keyword: #d73a49;
--color-code-tags-variable: var(--prim-color-alt-c-shade-100); --color-code-tags-variable: #005cc5;
--color-code-tags-definition: var(--prim-color-alt-e-shade-150); --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-default: var(--prim-color-secondary-shade-100);
--color-json-null: var(--prim-color-alt-c); --color-json-null: var(--prim-color-alt-c);
--color-json-boolean: var(--prim-color-alt-a); --color-json-boolean: var(--prim-color-alt-a);
@ -201,13 +209,16 @@
--color-json-highlight: var(--prim-gray-70); --color-json-highlight: var(--prim-gray-70);
--color-code-background: var(--prim-gray-0); --color-code-background: var(--prim-gray-0);
--color-code-background-readonly: var(--prim-gray-40); --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-foreground: var(--prim-gray-670);
--color-code-caret: var(--prim-gray-420); --color-code-caret: var(--prim-gray-820);
--color-code-selection: var(--prim-gray-120); --color-code-selection: #0366d625;
--color-code-gutterBackground: var(--prim-gray-0); --color-code-selection-highlight: #34d05840;
--color-code-gutterForeground: var(--prim-gray-320); --color-code-gutter-background: var(--prim-gray-0);
--color-code-tags-comment: var(--prim-gray-420); --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-line-break: var(--prim-gray-320);
--color-code-line-break: var(--prim-color-secondary-tint-200); --color-code-line-break: var(--prim-color-secondary-tint-200);

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import type { IRestApiContext } from '@/Interface'; import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest, request } from '@/utils/apiUtils';
export interface TestDefinitionRecord { export interface TestDefinitionRecord {
id: string; id: string;
name: string; name: string;
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
description?: string | null; description?: string | null;
updatedAt?: string; updatedAt?: string;
createdAt?: string; createdAt?: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string }>;
} }
interface CreateTestDefinitionParams { interface CreateTestDefinitionParams {
name: string; name: string;
workflowId: string; workflowId: string;
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
evaluationWorkflowId?: string | null; evaluationWorkflowId?: string | null;
annotationTagId?: string | null; annotationTagId?: string | null;
description?: string | null; description?: string | null;
mockedNodes?: Array<{ name: string }>;
} }
export interface UpdateTestResponse { export interface UpdateTestResponse {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
id: string; id: string;
name: string; name: string;
workflowId: string; workflowId: string;
description: string | null; description?: string | null;
annotationTag: string | null; annotationTag?: string | null;
evaluationWorkflowId: string | null; evaluationWorkflowId?: string | null;
annotationTagId: 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 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[] }>( return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
context, context,
'GET', 'GET',
endpoint, url,
); );
} }
export async function getTestDefinition(context: IRestApiContext, id: string) { 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( export async function createTestDefinition(
@ -71,3 +107,125 @@ export async function updateTestDefinition(
export async function deleteTestDefinition(context: IRestApiContext, id: string) { export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`); 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"> <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 type { ViewUpdate } from '@codemirror/view';
import { EditorView } from '@codemirror/view';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow'; import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { format } from 'prettier'; import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel'; import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree'; 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 { CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus'; import { codeNodeEditorEventBus } from '@/event-bus';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import { useCodeEditor } from '@/composables/useCodeEditor';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import AskAI from './AskAI/AskAI.vue'; import AskAI from './AskAI/AskAI.vue';
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
import { useCompleter } from './completer';
import { CODE_PLACEHOLDERS } from './constants'; import { CODE_PLACEHOLDERS } from './constants';
import { useLinter } from './linter'; 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 { useSettingsStore } from '@/stores/settings.store';
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
mode: CodeExecutionMode; mode: CodeExecutionMode;
@ -36,6 +28,7 @@ type Props = {
language?: CodeNodeEditorLanguage; language?: CodeNodeEditorLanguage;
isReadOnly?: boolean; isReadOnly?: boolean;
rows?: number; rows?: number;
id?: string;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -44,99 +37,57 @@ const props = withDefaults(defineProps<Props>(), {
language: 'javaScript', language: 'javaScript',
isReadOnly: false, isReadOnly: false,
rows: 4, rows: 4,
id: crypto.randomUUID(),
}); });
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
}>(); }>();
const message = useMessage(); 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 tabs = ref(['code', 'ask-ai']);
const activeTab = ref('code'); const activeTab = ref('code');
const hasChanges = ref(false);
const isLoadingAIResponse = ref(false); const isLoadingAIResponse = ref(false);
const codeNodeEditorRef = ref<HTMLDivElement>(); const codeNodeEditorRef = ref<HTMLDivElement>();
const codeNodeEditorContainerRef = ref<HTMLDivElement>(); const codeNodeEditorContainerRef = ref<HTMLDivElement>();
const hasManualChanges = ref(false);
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
const { createLinter } = useLinter(() => props.mode, editor);
const rootStore = useRootStore(); const rootStore = useRootStore();
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const settingsStore = useSettingsStore(); 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(() => { onMounted(() => {
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine); if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied); 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) { if (!props.modelValue) {
refreshPlaceholder();
emit('update:modelValue', placeholder.value); emit('update:modelValue', placeholder.value);
} }
}); });
@ -150,89 +101,12 @@ const askAiEnabled = computed(() => {
return settingsStore.isAskAiEnabled && props.language === 'javaScript'; return settingsStore.isAskAiEnabled && props.language === 'javaScript';
}); });
const placeholder = computed(() => { watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? ''; if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
}); emit('update:modelValue', placeholder.value);
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.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) { async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
// Confirm dialog if leaving ask-ai tab during loading // Confirm dialog if leaving ask-ai tab during loading
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) { if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
@ -243,69 +117,28 @@ async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
showCancelButton: true, showCancelButton: true,
}); });
if (confirmModal === 'confirm') { return confirmModal === 'confirm';
return true;
}
return false;
} }
return true; return true;
} }
async function onReplaceCode(code: string) { async function onAiReplaceCode(code: string) {
const formattedCode = await format(code, { const formattedCode = await format(code, {
parser: 'babel', parser: 'babel',
plugins: [jsParser, estree], plugins: [jsParser, estree],
}); });
editor.value?.dispatch({ emit('update:modelValue', formattedCode);
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
});
activeTab.value = 'code'; activeTab.value = 'code';
hasChanges.value = false; hasManualChanges.value = false;
} }
function onMouseOver(event: MouseEvent) { function onEditorUpdate(viewUpdate: ViewUpdate) {
const fromElement = event.relatedTarget as HTMLElement; trackCompletion(viewUpdate);
const containerRef = codeNodeEditorContainerRef.value; hasManualChanges.value = true;
emit('update:modelValue', readEditorValue());
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 diffApplied() { 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) { function trackCompletion(viewUpdate: ViewUpdate) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete')); const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
@ -342,7 +156,7 @@ function trackCompletion(viewUpdate: ViewUpdate) {
try { try {
// @ts-expect-error - undocumented fields // @ts-expect-error - undocumented fields
const { fromA, toB } = viewUpdate?.changedRanges[0]; 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('.'); const lastDotIndex = full.lastIndexOf('.');
let context = null; let context = null;
@ -379,16 +193,19 @@ function onAiLoadEnd() {
async function onDrop(value: string, event: MouseEvent) { async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return; 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> </script>
<template> <template>
<div <div
ref="codeNodeEditorContainerRef" ref="codeNodeEditorContainerRef"
:class="['code-node-editor', $style['code-node-editor-container'], language]" :class="['code-node-editor', $style['code-node-editor-container']]"
@mouseover="onMouseOver"
@mouseout="onMouseOut"
> >
<el-tabs <el-tabs
v-if="askAiEnabled" 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 --> <!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
<AskAI <AskAI
:key="activeTab" :key="activeTab"
:has-changes="hasChanges" :has-changes="hasManualChanges"
@replace-code="onReplaceCode" @replace-code="onAiReplaceCode"
@started-loading="onAiLoadStart" @started-loading="onAiLoadStart"
@finished-loading="onAiLoadEnd" @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_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. * 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 type { Diagnostic } from '@codemirror/lint';
import { linter } from '@codemirror/lint'; import { linter as codeMirrorLinter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view'; import type { EditorView } from '@codemirror/view';
import * as esprima from 'esprima-next'; import * as esprima from 'esprima-next';
import type { Node, MemberExpression } from 'estree'; import type { Node, MemberExpression } from 'estree';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow'; 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 { useI18n } from '@/composables/useI18n';
import { import {
@ -17,17 +17,17 @@ import { walk } from './utils';
export const useLinter = ( export const useLinter = (
mode: MaybeRefOrGetter<CodeExecutionMode>, mode: MaybeRefOrGetter<CodeExecutionMode>,
editor: MaybeRefOrGetter<EditorView | null>, language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
) => { ) => {
const i18n = useI18n(); const i18n = useI18n();
const linter = computed(() => {
function createLinter(language: CodeNodeEditorLanguage) { switch (toValue(language)) {
switch (language) {
case 'javaScript': 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[] { function lintSource(editorView: EditorView): Diagnostic[] {
const doc = editorView.state.doc.toString(); const doc = editorView.state.doc.toString();
@ -38,34 +38,7 @@ export const useLinter = (
try { try {
ast = esprima.parseScript(script, { range: true }); ast = esprima.parseScript(script, { range: true });
} catch (syntaxError) { } catch (syntaxError) {
let line; return [];
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 [];
}
} }
if (ast === null) return []; if (ast === null) return [];
@ -118,7 +91,7 @@ export const useLinter = (
walk(ast, isUnavailableVarInAllItems).forEach((node) => { walk(ast, isUnavailableVarInAllItems).forEach((node) => {
const [start, end] = getRange(node); const [start, end] = getRange(node);
const varName = getText(node); const varName = getText(editorView, node);
if (!varName) return; if (!varName) return;
@ -250,7 +223,7 @@ export const useLinter = (
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => { walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
const [start, end] = getRange(node.property); const [start, end] = getRange(node.property);
const method = getText(node.property); const method = getText(editorView, node.property);
if (!method) return; if (!method) return;
@ -444,7 +417,7 @@ export const useLinter = (
if (shadowStart && start > shadowStart) return; // skip shadow item if (shadowStart && start > shadowStart) return; // skip shadow item
const varName = getText(node); const varName = getText(editorView, node);
if (!varName) return; if (!varName) return;
@ -489,7 +462,7 @@ export const useLinter = (
!['json', 'binary'].includes(node.property.name); !['json', 'binary'].includes(node.property.name);
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => { walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
const varName = getText(node); const varName = getText(editorView, node);
if (!varName) return; if (!varName) return;
@ -636,19 +609,15 @@ export const useLinter = (
// helpers // helpers
// ---------------------------------- // ----------------------------------
function getText(node: RangeNode) { function getText(editorView: EditorView, node: RangeNode) {
const editorValue = toValue(editor);
if (!editorValue) return null;
const [start, end] = getRange(node); 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) { function getRange(node: RangeNode) {
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER); return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
} }
return { createLinter }; return linter;
}; };

View file

@ -32,16 +32,62 @@ interface ThemeSettings {
maxHeight?: string; maxHeight?: string;
minHeight?: string; minHeight?: string;
rows?: number; rows?: number;
highlightColors?: 'default' | 'html';
} }
export const codeNodeEditorTheme = ({ const codeEditorSyntaxHighlighting = syntaxHighlighting(
isReadOnly, HighlightStyle.define([
minHeight, { tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
maxHeight, {
rows, tag: [
highlightColors, tags.deleted,
}: ThemeSettings) => [ 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({ EditorView.theme({
'&': { '&': {
'font-size': BASE_STYLING.fontSize, 'font-size': BASE_STYLING.fontSize,
@ -54,13 +100,17 @@ export const codeNodeEditorTheme = ({
'.cm-content': { '.cm-content': {
fontFamily: BASE_STYLING.fontFamily, fontFamily: BASE_STYLING.fontFamily,
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)', 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': { '.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)', borderLeftColor: 'var(--color-code-caret)',
}, },
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': { '&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
backgroundColor: 'var(--color-code-selection)', {
}, background: 'var(--color-code-selection)',
},
'&.cm-editor': { '&.cm-editor': {
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}), ...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
borderColor: 'var(--border-color-base)', borderColor: 'var(--border-color-base)',
@ -75,13 +125,19 @@ export const codeNodeEditorTheme = ({
'.cm-activeLineGutter': { '.cm-activeLineGutter': {
backgroundColor: 'var(--color-code-lineHighlight)', backgroundColor: 'var(--color-code-lineHighlight)',
}, },
'.cm-lineNumbers .cm-activeLineGutter': {
color: 'var(--color-code-gutter-foreground-active)',
},
'.cm-gutters': { '.cm-gutters': {
backgroundColor: isReadOnly backgroundColor: isReadOnly
? 'var(--color-code-background-readonly)' ? 'var(--color-code-background-readonly)'
: 'var(--color-code-gutterBackground)', : 'var(--color-code-gutter-background)',
color: 'var(--color-code-gutterForeground)', color: 'var(--color-code-gutter-foreground)',
border: '0',
borderRadius: 'var(--border-radius-base)', borderRadius: 'var(--border-radius-base)',
borderRightColor: 'var(--border-color-base)', },
'.cm-gutterElement': {
padding: 0,
}, },
'.cm-tooltip': { '.cm-tooltip': {
maxWidth: BASE_STYLING.tooltip.maxWidth, maxWidth: BASE_STYLING.tooltip.maxWidth,
@ -92,11 +148,30 @@ export const codeNodeEditorTheme = ({
maxHeight: maxHeight ?? '100%', maxHeight: maxHeight ?? '100%',
...(isReadOnly ...(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': { '.cm-gutter,.cm-content': {
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'), 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': { '.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor, backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
color: 'var(--color-primary)', color: 'var(--color-primary)',
@ -106,103 +181,81 @@ export const codeNodeEditorTheme = ({
cursor: BASE_STYLING.diagnosticButton.cursor, cursor: BASE_STYLING.diagnosticButton.cursor,
}, },
'.cm-diagnostic-error': { '.cm-diagnostic-error': {
backgroundColor: 'var(--color-background-base)', backgroundColor: 'var(--color-infobox-background)',
}, },
'.cm-diagnosticText': { '.cm-diagnosticText': {
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-base)', 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' codeEditorSyntaxHighlighting,
? 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)',
},
]),
),
]; ];

View file

@ -162,6 +162,7 @@ function moveResource() {
<template #append> <template #append>
<div :class="$style.cardActions" @click.stop> <div :class="$style.cardActions" @click.stop>
<ProjectCardBadge <ProjectCardBadge
:class="$style.cardBadge"
:resource="data" :resource="data"
:resource-type="ResourceType.Credential" :resource-type="ResourceType.Credential"
:resource-type-label="resourceTypeLabel" :resource-type-label="resourceTypeLabel"
@ -180,9 +181,10 @@ function moveResource() {
<style lang="scss" module> <style lang="scss" module>
.cardLink { .cardLink {
--card--padding: 0 0 0 var(--spacing-s);
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
cursor: pointer; cursor: pointer;
padding: 0 0 0 var(--spacing-s);
align-items: stretch; align-items: stretch;
&:hover { &:hover {
@ -215,4 +217,22 @@ function moveResource() {
padding: 0 var(--spacing-s) 0 0; padding: 0 var(--spacing-s) 0 0;
cursor: default; 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> </style>

View file

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

View file

@ -3,6 +3,8 @@ import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants'; import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
import { useCredentialsStore } from '@/stores/credentials.store';
import type { ICredentialsResponse } from '@/Interface';
vi.mock('@/permissions', () => ({ vi.mock('@/permissions', () => ({
getResourcePermissions: vi.fn(() => ({ getResourcePermissions: vi.fn(() => ({
@ -23,6 +25,10 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
}, },
[STORES.SETTINGS]: { [STORES.SETTINGS]: {
settings: { settings: {
enterprise: {
sharing: true,
externalSecrets: false,
},
templates: { templates: {
host: '', host: '',
}, },
@ -67,4 +73,54 @@ describe('CredentialEdit', () => {
}); });
await retry(() => expect(queryByTestId('credential-save-button')).not.toBeInTheDocument()); 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; margin-bottom: 0;
:global(.el-dialog__body) { :global(.el-dialog__body) {
background-color: var(--color-expression-editor-modal-background);
height: 100%; height: 100%;
padding: var(--spacing-s); 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 { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { forceParse } from '@/utils/forceParse'; import { forceParse } from '@/utils/forceParse';
import { completionStatus } from '@codemirror/autocomplete';
import { inputTheme } from './theme'; import { inputTheme } from './theme';
import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { useExpressionEditor } from '@/composables/useExpressionEditor';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip'; import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions'; import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions'; import { removeExpressionPrefix } from '@/utils/expressions';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
type Props = { type Props = {
modelValue: string; modelValue: string;
@ -41,24 +35,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>(); const root = ref<HTMLElement>();
const extensions = computed(() => [ const extensions = computed(() => [
inputTheme(props.isReadOnly), inputTheme(props.isReadOnly),
Prec.highest( Prec.highest(keymap.of(editorKeymap)),
keymap.of([
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
emit('close');
}
return false;
},
},
]),
),
n8nLang(), n8nLang(),
n8nAutocompletion(), n8nAutocompletion(),
mappingDropCursor(), mappingDropCursor(),
@ -66,7 +43,7 @@ const extensions = computed(() => [
history(), history(),
expressionInputHandler(), expressionInputHandler(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }), EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
infoBoxTooltips(), infoBoxTooltips(),
]); ]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue)); const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));

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