Merge remote-tracking branch 'origin/master' into pay-2440-bug-credential-testing-is-not-working

This commit is contained in:
Csaba Tuncsik 2025-01-08 13:42:28 +01:00
commit ada03278a2
No known key found for this signature in database
234 changed files with 18651 additions and 1857 deletions

View file

@ -17,7 +17,9 @@ jobs:
build: build:
name: Install & Build name: Install & Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/') if: |
(github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')) &&
!contains(github.event.pull_request.labels.*.name, 'community')
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4.1.1
- run: corepack enable - run: corepack enable

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

@ -171,9 +171,16 @@ describe('Workflow Actions', () => {
cy.get('#node-creator').should('not.exist'); cy.get('#node-creator').should('not.exist');
WorkflowPage.actions.hitSelectAll(); WorkflowPage.actions.hitSelectAll();
cy.get('.jtk-drag-selected').should('have.length', 2);
WorkflowPage.actions.hitCopy(); WorkflowPage.actions.hitCopy();
successToast().should('exist'); successToast().should('exist');
// Both nodes should be copied
cy.window()
.its('navigator.clipboard')
.then((clip) => clip.readText())
.then((text) => {
const copiedWorkflow = JSON.parse(text);
expect(copiedWorkflow.nodes).to.have.length(2);
});
}); });
it('should paste nodes (both current and old node versions)', () => { it('should paste nodes (both current and old node versions)', () => {
@ -345,7 +352,15 @@ describe('Workflow Actions', () => {
WorkflowPage.actions.hitDeleteAllNodes(); WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.length', 0); WorkflowPage.getters.canvasNodes().should('have.length', 0);
// Button should be disabled // Button should be disabled
WorkflowPage.getters.executeWorkflowButton().should('be.disabled'); cy.ifCanvasVersion(
() => {
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
},
() => {
// In new canvas, button does not exist when there are no nodes
WorkflowPage.getters.executeWorkflowButton().should('not.exist');
},
);
// Keyboard shortcut should not work // Keyboard shortcut should not work
WorkflowPage.actions.hitExecuteWorkflow(); WorkflowPage.actions.hitExecuteWorkflow();
successToast().should('not.exist'); successToast().should('not.exist');

View file

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

View file

@ -12,7 +12,7 @@
"format:check": "biome ci .", "format:check": "biome ci .",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lintfix": "eslint . --fix", "lintfix": "eslint . --fix",
"develop": "cd ..; pnpm dev", "develop": "cd ..; pnpm dev:e2e:server",
"start": "cd ..; pnpm start" "start": "cd ..; pnpm start"
}, },
"devDependencies": { "devDependencies": {

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

@ -18,6 +18,11 @@
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner", "dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui", "dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", "dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
"dev:e2e:server": "run-p start dev:fe:editor",
"clean": "turbo run clean --parallel", "clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
"format": "turbo run format && node scripts/format.mjs", "format": "turbo run format && node scripts/format.mjs",
@ -55,6 +60,7 @@
"lefthook": "^1.7.15", "lefthook": "^1.7.15",
"nock": "^13.3.2", "nock": "^13.3.2",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"npm-run-all2": "^7.0.2",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"run-script-os": "^1.0.7", "run-script-os": "^1.0.7",

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
*
* 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.
*
* 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

@ -89,14 +89,14 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
"value": "insert", "value": "insert",
}, },
{ {
"action": "Retrieve documents for AI processing as Vector Store", "action": "Retrieve documents for Chain/Tool as Vector Store",
"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",
"name": "Retrieve Documents (As Vector Store for AI Agent)", "name": "Retrieve Documents (As Vector Store for Chain/Tool)",
"outputConnectionType": "ai_vectorStore", "outputConnectionType": "ai_vectorStore",
"value": "retrieve", "value": "retrieve",
}, },
{ {
"action": "Retrieve documents for AI processing as Tool", "action": "Retrieve documents for AI Agent 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",
"name": "Retrieve Documents (As Tool for AI Agent)", "name": "Retrieve Documents (As Tool for AI Agent)",
"outputConnectionType": "ai_tool", "outputConnectionType": "ai_tool",

View file

@ -111,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,
}, },
{ {

View file

@ -23,8 +23,13 @@ export class BaseRunnerConfig {
@Env('N8N_RUNNERS_MAX_PAYLOAD') @Env('N8N_RUNNERS_MAX_PAYLOAD')
maxPayloadSize: number = 1024 * 1024 * 1024; maxPayloadSize: number = 1024 * 1024 * 1024;
/**
* How many concurrent tasks can a runner execute at a time
*
* 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 runner may be idle for before exit. Intended * How long (in seconds) a runner may be idle for before exit. Intended
@ -37,8 +42,15 @@ export class BaseRunnerConfig {
@Env('GENERIC_TIMEZONE') @Env('GENERIC_TIMEZONE')
timezone: string = 'America/New_York'; timezone: string = 'America/New_York';
/**
* 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.
*
* 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
@Nested @Nested
healthcheckServer!: HealthcheckServerConfig; healthcheckServer!: HealthcheckServerConfig;

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

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

@ -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';
@ -90,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: {
@ -110,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,
}, },
}, },
}); });
@ -156,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 () => {
@ -167,6 +183,7 @@ describe('TestRunnerService', () => {
testRunRepository, testRunRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(),
); );
expect(testRunnerService).toBeInstanceOf(TestRunnerService); expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -181,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({
@ -218,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({
@ -298,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 () => {
@ -315,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({
@ -388,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(
@ -412,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,5 +1,6 @@
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 { NodeConnectionType, Workflow } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
@ -45,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,
) {} ) {}
/** /**
@ -134,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
@ -146,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
@ -223,52 +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,
pastExecution.executionData.workflowData, pastExecution.executionData.workflowData,
test.mockedNodes, test.mockedNodes,
user.id, 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

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

@ -147,6 +147,12 @@ export class WorkflowExecutionService {
triggerToStartFrom, triggerToStartFrom,
}; };
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
if (pinnedTrigger && !hasRunData(pinnedTrigger)) {
data.startNodes = [{ name: pinnedTrigger.name, sourceData: null }];
}
/** /**
* Historically, manual executions in scaling mode ran in the main process, * Historically, manual executions in scaling mode ran in the main process,
* so some execution details were never persisted in the database. * so some execution details were never persisted in the database.
@ -160,7 +166,7 @@ export class WorkflowExecutionService {
) { ) {
data.executionData = { data.executionData = {
startData: { startData: {
startNodes, startNodes: data.startNodes,
destinationNode, destinationNode,
}, },
resultData: { resultData: {
@ -176,12 +182,6 @@ export class WorkflowExecutionService {
}; };
} }
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
if (pinnedTrigger && !hasRunData(pinnedTrigger)) {
data.startNodes = [{ name: pinnedTrigger.name, sourceData: null }];
}
const executionId = await this.workflowRunner.run(data); const executionId = await this.workflowRunner.run(data);
return { return {

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

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

@ -11,7 +11,7 @@
"test": "vitest run", "test": "vitest run",
"test:dev": "vitest", "test:dev": "vitest",
"build:storybook": "storybook build", "build:storybook": "storybook build",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006 --no-open",
"chromatic": "chromatic", "chromatic": "chromatic",
"format": "biome format --write . && prettier --write . --ignore-path ../../.prettierignore", "format": "biome format --write . && prettier --write . --ignore-path ../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../.prettierignore", "format:check": "biome ci . && prettier --check . --ignore-path ../../.prettierignore",

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

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

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

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

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

View file

@ -11,11 +11,16 @@ import { useRootStore } from '@/stores/root.store';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { renderComponent } from '@/__tests__/render'; import { renderComponent } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { useTelemetry } from '@/composables/useTelemetry';
vi.mock('@/composables/useToast', () => ({ vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(), useToast: vi.fn(),
})); }));
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(),
}));
vi.mock('@/stores/settings.store', () => ({ vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(), useSettingsStore: vi.fn(),
})); }));
@ -55,7 +60,17 @@ const assertUserCanClaimCredits = () => {
}; };
const assertUserClaimedCredits = () => { const assertUserClaimedCredits = () => {
expect(screen.getByText('Claimed 100 free OpenAI API credits')).toBeInTheDocument(); expect(
screen.getByText(
'Claimed 100 free OpenAI API credits! Please note these free credits are only for the following models:',
),
).toBeInTheDocument();
expect(
screen.getByText(
'gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest',
),
).toBeInTheDocument();
}; };
describe('FreeAiCreditsCallout', () => { describe('FreeAiCreditsCallout', () => {
@ -86,7 +101,7 @@ describe('FreeAiCreditsCallout', () => {
}); });
(usePostHog as any).mockReturnValue({ (usePostHog as any).mockReturnValue({
isFeatureEnabled: vi.fn().mockReturnValue(true), getVariant: vi.fn().mockReturnValue('variant'),
}); });
(useProjectsStore as any).mockReturnValue({ (useProjectsStore as any).mockReturnValue({
@ -100,6 +115,10 @@ describe('FreeAiCreditsCallout', () => {
(useToast as any).mockReturnValue({ (useToast as any).mockReturnValue({
showError: vi.fn(), showError: vi.fn(),
}); });
(useTelemetry as any).mockReturnValue({
track: vi.fn(),
});
}); });
it('should shows the claim callout when the user can claim credits', () => { it('should shows the claim callout when the user can claim credits', () => {
@ -120,6 +139,7 @@ describe('FreeAiCreditsCallout', () => {
await fireEvent.click(claimButton); await fireEvent.click(claimButton);
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id'); expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
expect(useTelemetry().track).toHaveBeenCalledWith('User claimed OpenAI credits');
assertUserClaimedCredits(); assertUserClaimedCredits();
}); });
@ -150,7 +170,7 @@ describe('FreeAiCreditsCallout', () => {
it('should not be able to claim credits if user it is not in experiment', async () => { it('should not be able to claim credits if user it is not in experiment', async () => {
(usePostHog as any).mockReturnValue({ (usePostHog as any).mockReturnValue({
isFeatureEnabled: vi.fn().mockReturnValue(false), getVariant: vi.fn().mockReturnValue('control'),
}); });
renderComponent(FreeAiCreditsCallout); renderComponent(FreeAiCreditsCallout);

View file

@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { AI_CREDITS_EXPERIMENT } from '@/constants'; import { AI_CREDITS_EXPERIMENT } from '@/constants';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
@ -9,8 +10,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.'; const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';
@ -27,11 +27,12 @@ const showSuccessCallout = ref(false);
const claimingCredits = ref(false); const claimingCredits = ref(false);
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const postHogStore = usePostHog(); const posthogStore = usePostHog();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
const toast = useToast(); const toast = useToast();
@ -57,7 +58,7 @@ const userCanClaimOpenAiCredits = computed(() => {
return ( return (
settingsStore.isAiCreditsEnabled && settingsStore.isAiCreditsEnabled &&
activeNodeHasOpenAiApiCredential.value && activeNodeHasOpenAiApiCredential.value &&
postHogStore.isFeatureEnabled(AI_CREDITS_EXPERIMENT.name) && posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === AI_CREDITS_EXPERIMENT.variant &&
!userHasOpenAiCredentialAlready.value && !userHasOpenAiCredentialAlready.value &&
!userHasClaimedAiCreditsAlready.value !userHasClaimedAiCreditsAlready.value
); );
@ -73,6 +74,8 @@ const onClaimCreditsClicked = async () => {
usersStore.currentUser.settings.userClaimedAiCredits = true; usersStore.currentUser.settings.userClaimedAiCredits = true;
} }
telemetry.track('User claimed OpenAI credits');
showSuccessCallout.value = true; showSuccessCallout.value = true;
} catch (e) { } catch (e) {
toast.showError( toast.showError(
@ -108,11 +111,16 @@ const onClaimCreditsClicked = async () => {
</template> </template>
</n8n-callout> </n8n-callout>
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle"> <n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
{{ <n8n-text>
i18n.baseText('freeAi.credits.callout.success.title', { {{
interpolate: { credits: settingsStore.aiCreditsQuota }, i18n.baseText('freeAi.credits.callout.success.title.part1', {
}) interpolate: { credits: settingsStore.aiCreditsQuota },
}} })
}}</n8n-text
>&nbsp;
<n8n-text :bold="true">
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
>
</n8n-callout> </n8n-callout>
</div> </div>
</template> </template>

View file

@ -22,19 +22,14 @@ import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss'; import cssParser from 'prettier/plugins/postcss';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
import { htmlEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { htmlEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions'; import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { import { editorKeymap } from '@/plugins/codemirror/keymap';
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n'; import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { codeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types'; import type { Range, Section } from './types';
import { nonTakenRanges } from './utils'; import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
@ -67,16 +62,13 @@ const extensions = computed(() => [
), ),
autoCloseTags, autoCloseTags,
expressionInputHandler(), expressionInputHandler(),
Prec.highest( Prec.highest(keymap.of(editorKeymap)),
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
indentOnInput(), indentOnInput(),
codeNodeEditorTheme({ codeEditorTheme({
isReadOnly: props.isReadOnly, isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh', maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '20vh', minHeight: '20vh',
rows: props.rows, rows: props.rows,
highlightColors: 'html',
}), }),
lineNumbers(), lineNumbers(),
highlightActiveLineGutter(), highlightActiveLineGutter(),

View file

@ -97,7 +97,7 @@ onMounted(() => {
extensions: [ extensions: [
EditorState.readOnly.of(true), EditorState.readOnly.of(true),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }), EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
...props.extensions, ...props.extensions,
], ],
}), }),

View file

@ -7,12 +7,7 @@ import { computed, ref, watch } from 'vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { import { editorKeymap } from '@/plugins/codemirror/keymap';
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
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';
@ -42,9 +37,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>(); const root = ref<HTMLElement>();
const extensions = computed(() => [ const extensions = computed(() => [
Prec.highest( Prec.highest(keymap.of(editorKeymap)),
keymap.of([...tabKeyMap(false), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
),
n8nLang(), n8nLang(),
n8nAutocompletion(), n8nAutocompletion(),
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }), inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { history, toggleComment } from '@codemirror/commands'; import { history } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { foldGutter, indentOnInput } from '@codemirror/language'; import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint'; import { lintGutter } from '@codemirror/lint';
@ -16,14 +16,9 @@ import {
} from '@codemirror/view'; } from '@codemirror/view';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { import { editorKeymap } from '@/plugins/codemirror/keymap';
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { codeEditorTheme } from '../CodeNodeEditor/theme';
type Props = { type Props = {
modelValue: string; modelValue: string;
@ -85,7 +80,7 @@ const extensions = computed(() => {
lineNumbers(), lineNumbers(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly), EditorState.readOnly.of(props.isReadOnly),
codeNodeEditorTheme({ codeEditorTheme({
isReadOnly: props.isReadOnly, isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh', maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh', minHeight: '20vh',
@ -96,15 +91,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) { if (!props.isReadOnly) {
extensionsToApply.push( extensionsToApply.push(
history(), history(),
Prec.highest( Prec.highest(keymap.of(editorKeymap)),
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
lintGutter(), lintGutter(),
n8nAutocompletion(), n8nAutocompletion(),
indentOnInput(), indentOnInput(),

View file

@ -15,15 +15,10 @@ import {
lineNumbers, lineNumbers,
} from '@codemirror/view'; } from '@codemirror/view';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme'; import { editorKeymap } from '@/plugins/codemirror/keymap';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = { type Props = {
@ -48,7 +43,7 @@ const extensions = computed(() => {
lineNumbers(), lineNumbers(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly), EditorState.readOnly.of(props.isReadOnly),
codeNodeEditorTheme({ codeEditorTheme({
isReadOnly: props.isReadOnly, isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh', maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh', minHeight: '20vh',
@ -58,9 +53,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) { if (!props.isReadOnly) {
extensionsToApply.push( extensionsToApply.push(
history(), history(),
Prec.highest( Prec.highest(keymap.of(editorKeymap)),
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
createLinter(jsonParseLinter()), createLinter(jsonParseLinter()),
lintGutter(), lintGutter(),
n8nAutocompletion(), n8nAutocompletion(),

View file

@ -43,6 +43,25 @@ const executionToReturnTo = ref('');
const dirtyState = ref(false); const dirtyState = ref(false);
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false); const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
// Track the routes that are used for the tabs
// This is used to determine which tab to show when the route changes
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
const testDefinitionRoutes: VIEWS[] = [
VIEWS.TEST_DEFINITION,
VIEWS.TEST_DEFINITION_EDIT,
VIEWS.TEST_DEFINITION_RUNS,
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
];
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
const executionRoutes: VIEWS[] = [
VIEWS.EXECUTION_HOME,
VIEWS.WORKFLOW_EXECUTIONS,
VIEWS.EXECUTION_PREVIEW,
];
const tabBarItems = computed(() => { const tabBarItems = computed(() => {
const items = [ const items = [
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') }, { value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
@ -92,24 +111,30 @@ onMounted(async () => {
syncTabsWithRoute(route); syncTabsWithRoute(route);
}); });
function isViewRoute(name: unknown): name is VIEWS {
return (
typeof name === 'string' &&
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
);
}
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void { function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) { // Map route types to their corresponding tab in the header
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION; const routeTabMapping = [
} { routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
if ( { routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
to.name === VIEWS.EXECUTION_HOME || { routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
to.name === VIEWS.WORKFLOW_EXECUTIONS || ];
to.name === VIEWS.EXECUTION_PREVIEW
) { // Update the active tab based on the current route
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS; if (to.name && isViewRoute(to.name)) {
} else if ( const matchingTab = routeTabMapping.find(({ routes }) => routes.includes(to.name as VIEWS));
to.name === VIEWS.WORKFLOW || if (matchingTab) {
to.name === VIEWS.NEW_WORKFLOW || activeHeaderTab.value = matchingTab.tab;
to.name === VIEWS.EXECUTION_DEBUG }
) {
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
} }
// Store the current workflow ID, but only if it's not a new workflow
if (to.params.name !== 'new' && typeof to.params.name === 'string') { if (to.params.name !== 'new' && typeof to.params.name === 'string') {
workflowToReturnTo.value = to.params.name; workflowToReturnTo.value = to.params.name;
} }

View file

@ -801,7 +801,6 @@ $--header-spacing: 20px;
color: $custom-font-dark; color: $custom-font-dark;
font-size: 15px; font-size: 15px;
display: block; display: block;
min-width: 150px;
} }
.activator { .activator {
@ -848,6 +847,14 @@ $--header-spacing: 20px;
gap: var(--spacing-m); gap: var(--spacing-m);
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@include mixins.breakpoint('xs-only') {
.name {
:deep(input) {
min-width: 180px;
}
}
}
</style> </style>
<style module lang="scss"> <style module lang="scss">

View file

@ -476,6 +476,10 @@ const shortPath = computed<string>(() => {
return short.join('.'); return short.join('.');
}); });
const parameterId = computed(() => {
return `${node.value?.id ?? crypto.randomUUID()}${props.path}`;
});
const isResourceLocatorParameter = computed<boolean>(() => { const isResourceLocatorParameter = computed<boolean>(() => {
return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector'; return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
}); });
@ -1092,21 +1096,20 @@ onUpdated(async () => {
" "
> >
<el-dialog <el-dialog
width="calc(100% - var(--spacing-3xl))"
:class="$style.modal"
:model-value="codeEditDialogVisible" :model-value="codeEditDialogVisible"
:append-to="`#${APP_MODALS_ELEMENT_ID}`" :append-to="`#${APP_MODALS_ELEMENT_ID}`"
width="80%"
:title="`${i18n.baseText('codeEdit.edit')} ${i18n :title="`${i18n.baseText('codeEdit.edit')} ${i18n
.nodeText() .nodeText()
.inputLabelDisplayName(parameter, path)}`" .inputLabelDisplayName(parameter, path)}`"
:before-close="closeCodeEditDialog" :before-close="closeCodeEditDialog"
data-test-id="code-editor-fullscreen" data-test-id="code-editor-fullscreen"
> >
<div <div class="ignore-key-press-canvas code-edit-dialog">
:key="codeEditDialogVisible.toString()"
class="ignore-key-press-canvas code-edit-dialog"
>
<CodeNodeEditor <CodeNodeEditor
v-if="editorType === 'codeNodeEditor'" v-if="editorType === 'codeNodeEditor'"
:id="parameterId"
:mode="codeEditorMode" :mode="codeEditorMode"
:model-value="modelValueString" :model-value="modelValueString"
:default-value="parameter.default" :default-value="parameter.default"
@ -1116,7 +1119,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced" @update:model-value="valueChangedDebounced"
/> />
<HtmlEditor <HtmlEditor
v-else-if="editorType === 'htmlEditor'" v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
:model-value="modelValueString" :model-value="modelValueString"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@ -1126,7 +1129,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced" @update:model-value="valueChangedDebounced"
/> />
<SqlEditor <SqlEditor
v-else-if="editorType === 'sqlEditor'" v-else-if="editorType === 'sqlEditor' && !codeEditDialogVisible"
:model-value="modelValueString" :model-value="modelValueString"
:dialect="getArgument('sqlDialect')" :dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
@ -1135,7 +1138,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced" @update:model-value="valueChangedDebounced"
/> />
<JsEditor <JsEditor
v-else-if="editorType === 'jsEditor'" v-else-if="editorType === 'jsEditor' && !codeEditDialogVisible"
:model-value="modelValueString" :model-value="modelValueString"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@ -1145,7 +1148,7 @@ onUpdated(async () => {
/> />
<JsonEditor <JsonEditor
v-else-if="parameter.type === 'json'" v-else-if="parameter.type === 'json' && !codeEditDialogVisible"
:model-value="modelValueString" :model-value="modelValueString"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@ -1166,8 +1169,8 @@ onUpdated(async () => {
></TextEdit> ></TextEdit>
<CodeNodeEditor <CodeNodeEditor
v-if="editorType === 'codeNodeEditor' && isCodeNode" v-if="editorType === 'codeNodeEditor' && isCodeNode && !codeEditDialogVisible"
:key="'code-' + codeEditDialogVisible.toString()" :id="parameterId"
:mode="codeEditorMode" :mode="codeEditorMode"
:model-value="modelValueString" :model-value="modelValueString"
:default-value="parameter.default" :default-value="parameter.default"
@ -1191,8 +1194,7 @@ onUpdated(async () => {
</CodeNodeEditor> </CodeNodeEditor>
<HtmlEditor <HtmlEditor
v-else-if="editorType === 'htmlEditor'" v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
:key="'html-' + codeEditDialogVisible.toString()"
:model-value="modelValueString" :model-value="modelValueString"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@ -1214,7 +1216,6 @@ onUpdated(async () => {
<SqlEditor <SqlEditor
v-else-if="editorType === 'sqlEditor'" v-else-if="editorType === 'sqlEditor'"
:key="'sql-' + codeEditDialogVisible.toString()"
:model-value="modelValueString" :model-value="modelValueString"
:dialect="getArgument('sqlDialect')" :dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
@ -1235,7 +1236,6 @@ onUpdated(async () => {
<JsEditor <JsEditor
v-else-if="editorType === 'jsEditor'" v-else-if="editorType === 'jsEditor'"
:key="'js-' + codeEditDialogVisible.toString()"
:model-value="modelValueString" :model-value="modelValueString"
:is-read-only="isReadOnly || editorIsReadOnly" :is-read-only="isReadOnly || editorIsReadOnly"
:rows="editorRows" :rows="editorRows"
@ -1257,7 +1257,6 @@ onUpdated(async () => {
<JsonEditor <JsonEditor
v-else-if="parameter.type === 'json'" v-else-if="parameter.type === 'json'"
:key="'json-' + codeEditDialogVisible.toString()"
:model-value="modelValueString" :model-value="modelValueString"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@ -1278,6 +1277,7 @@ onUpdated(async () => {
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()"> <div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
<CodeNodeEditor <CodeNodeEditor
v-if="!codeEditDialogVisible" v-if="!codeEditDialogVisible"
:id="parameterId"
:mode="codeEditorMode" :mode="codeEditorMode"
:model-value="modelValueString" :model-value="modelValueString"
:language="editorLanguage" :language="editorLanguage"
@ -1630,8 +1630,8 @@ onUpdated(async () => {
.textarea-modal-opener { .textarea-modal-opener {
position: absolute; position: absolute;
right: 0; right: 1px;
bottom: 0; bottom: 1px;
background-color: var(--color-code-background); background-color: var(--color-code-background);
padding: 3px; padding: 3px;
line-height: 9px; line-height: 9px;
@ -1639,6 +1639,8 @@ onUpdated(async () => {
border-top-left-radius: var(--border-radius-base); border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base); border-bottom-right-radius: var(--border-radius-base);
cursor: pointer; cursor: pointer;
border-right: none;
border-bottom: none;
svg { svg {
width: 9px !important; width: 9px !important;
@ -1660,7 +1662,7 @@ onUpdated(async () => {
} }
.code-edit-dialog { .code-edit-dialog {
height: 70vh; height: 100%;
.code-node-editor { .code-node-editor {
height: 100%; height: 100%;
@ -1668,7 +1670,25 @@ onUpdated(async () => {
} }
</style> </style>
<style lang="scss" module> <style lang="css" module>
.modal {
--dialog-close-top: var(--spacing-m);
display: flex;
flex-direction: column;
overflow: clip;
height: calc(100% - var(--spacing-4xl));
margin-bottom: 0;
:global(.el-dialog__header) {
padding-bottom: 0;
}
:global(.el-dialog__body) {
height: calc(100% - var(--spacing-3xl));
padding: var(--spacing-s);
}
}
.tipVisible { .tipVisible {
--input-border-bottom-left-radius: 0; --input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0; --input-border-bottom-right-radius: 0;

View file

@ -63,7 +63,6 @@ const state = reactive({
value: {}, value: {},
matchingColumns: [] as string[], matchingColumns: [] as string[],
schema: [] as ResourceMapperField[], schema: [] as ResourceMapperField[],
ignoreTypeMismatchErrors: false,
attemptToConvertTypes: false, attemptToConvertTypes: false,
// This should always be true if `showTypeConversionOptions` is provided // This should always be true if `showTypeConversionOptions` is provided
// It's used to avoid accepting any value as string without casting it // It's used to avoid accepting any value as string without casting it
@ -664,23 +663,6 @@ defineExpose({
} }
" "
/> />
<ParameterInputFull
:parameter="{
name: 'ignoreTypeMismatchErrors',
type: 'boolean',
displayName: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.displayName'),
default: false,
description: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.description'),
}"
:path="props.path + '.ignoreTypeMismatchErrors'"
:value="state.paramValue.ignoreTypeMismatchErrors"
@update="
(x: IUpdateInformation<NodeParameterValueType>) => {
state.paramValue.ignoreTypeMismatchErrors = x.value as boolean;
emitValueChanged();
}
"
/>
</div> </div>
</div> </div>
</template> </template>

View file

@ -282,7 +282,7 @@ describe('RunData', () => {
}); });
expect(getByTestId('related-execution-link')).toBeInTheDocument(); expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123'); expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata); expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL); expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
@ -311,7 +311,7 @@ describe('RunData', () => {
}); });
expect(getByTestId('related-execution-link')).toBeInTheDocument(); expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Parent Execution 123'); expect(getByTestId('related-execution-link')).toHaveTextContent('View parent execution');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata); expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL); expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
@ -344,7 +344,7 @@ describe('RunData', () => {
}); });
expect(getByTestId('related-execution-link')).toBeInTheDocument(); expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123'); expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution 123');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata); expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL); expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
@ -389,7 +389,7 @@ describe('RunData', () => {
}); });
expect(getByTestId('related-execution-link')).toBeInTheDocument(); expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123'); expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution 123');
expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument(); expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument();
expect(getByTestId('run-selector')).toBeInTheDocument(); expect(getByTestId('run-selector')).toBeInTheDocument();

View file

@ -1263,9 +1263,13 @@ function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
} }
if (task.subExecution) { if (task.subExecution) {
return i18n.baseText('runData.openSubExecution', { if (activeTaskMetadata.value?.subExecutionsCount === 1) {
interpolate: { id: task.subExecution.executionId }, return i18n.baseText('runData.openSubExecutionSingle');
}); } else {
return i18n.baseText('runData.openSubExecutionWithId', {
interpolate: { id: task.subExecution.executionId },
});
}
} }
return; return;

View file

@ -149,7 +149,7 @@ const outputError = computed(() => {
> >
<N8nIcon icon="external-link-alt" size="xsmall" /> <N8nIcon icon="external-link-alt" size="xsmall" />
{{ {{
i18n.baseText('runData.openSubExecution', { i18n.baseText('runData.openSubExecutionWithId', {
interpolate: { interpolate: {
id: runMeta.subExecution?.executionId, id: runMeta.subExecution?.executionId,
}, },

View file

@ -439,7 +439,7 @@ watch(focusedMappableInput, (curr) => {
> >
<N8nTooltip <N8nTooltip
:content=" :content="
i18n.baseText('runData.table.inspectSubExecution', { i18n.baseText('runData.table.viewSubExecution', {
interpolate: { interpolate: {
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`, id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
}, },
@ -575,7 +575,7 @@ watch(focusedMappableInput, (curr) => {
> >
<N8nTooltip <N8nTooltip
:content=" :content="
i18n.baseText('runData.table.inspectSubExecution', { i18n.baseText('runData.table.viewSubExecution', {
interpolate: { interpolate: {
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`, id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
}, },

View file

@ -1,18 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue'; import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { codeNodeEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions'; import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { import { editorKeymap } from '@/plugins/codemirror/keymap';
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { ifNotIn } from '@codemirror/autocomplete'; import { ifNotIn } from '@codemirror/autocomplete';
import { history, toggleComment } from '@codemirror/commands'; import { history } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Prec, type Line } from '@codemirror/state'; import { Prec, type Line } from '@codemirror/state';
import { import {
@ -34,10 +30,9 @@ import {
StandardSQL, StandardSQL,
keywordCompletionSource, keywordCompletionSource,
} from '@n8n/codemirror-lang-sql'; } from '@n8n/codemirror-lang-sql';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
const SQL_DIALECTS = { const SQL_DIALECTS = {
StandardSQL, StandardSQL,
@ -87,7 +82,7 @@ const extensions = computed(() => {
const baseExtensions = [ const baseExtensions = [
sqlWithN8nLanguageSupport(), sqlWithN8nLanguageSupport(),
expressionInputHandler(), expressionInputHandler(),
codeNodeEditorTheme({ codeEditorTheme({
isReadOnly: props.isReadOnly, isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh', maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '10vh', minHeight: '10vh',
@ -100,15 +95,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) { if (!props.isReadOnly) {
return baseExtensions.concat([ return baseExtensions.concat([
history(), history(),
Prec.highest( Prec.highest(keymap.of(editorKeymap)),
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
n8nAutocompletion(), n8nAutocompletion(),
indentOnInput(), indentOnInput(),
highlightActiveLine(), highlightActiveLine(),
@ -185,10 +172,10 @@ function line(lineNumber: number): Line | null {
} }
} }
function highlightLine(lineNumber: number | 'final') { function highlightLine(lineNumber: number | 'last') {
if (!editor.value) return; if (!editor.value) return;
if (lineNumber === 'final') { if (lineNumber === 'last') {
editor.value.dispatch({ editor.value.dispatch({
selection: { anchor: editor.value.state.doc.length }, selection: { anchor: editor.value.state.doc.length },
}); });

View file

@ -16,7 +16,10 @@ interface TagsDropdownProps {
allTags: ITag[]; allTags: ITag[];
isLoading: boolean; isLoading: boolean;
tagsById: Record<string, ITag>; tagsById: Record<string, ITag>;
createEnabled?: boolean;
manageEnabled?: boolean;
createTag?: (name: string) => Promise<ITag>; createTag?: (name: string) => Promise<ITag>;
multipleLimit?: number;
} }
const i18n = useI18n(); const i18n = useI18n();
@ -27,6 +30,10 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
placeholder: '', placeholder: '',
modelValue: () => [], modelValue: () => [],
eventBus: null, eventBus: null,
createEnabled: true,
manageEnabled: true,
createTag: undefined,
multipleLimit: 0,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -59,6 +66,17 @@ const appliedTags = computed<string[]>(() => {
return props.modelValue.filter((id: string) => props.tagsById[id]); return props.modelValue.filter((id: string) => props.tagsById[id]);
}); });
const containerClasses = computed(() => {
return { 'tags-container': true, focused: focused.value };
});
const dropdownClasses = computed(() => ({
'tags-dropdown': true,
[`tags-dropdown-${dropdownId}`]: true,
'tags-dropdown-create-enabled': props.createEnabled,
'tags-dropdown-manage-enabled': props.manageEnabled,
}));
watch( watch(
() => props.allTags, () => props.allTags,
() => { () => {
@ -189,7 +207,7 @@ onClickOutside(
</script> </script>
<template> <template>
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop> <div ref="container" :class="containerClasses" @keydown.stop>
<N8nSelect <N8nSelect
ref="selectRef" ref="selectRef"
:teleported="true" :teleported="true"
@ -199,16 +217,17 @@ onClickOutside(
:filter-method="filterOptions" :filter-method="filterOptions"
filterable filterable
multiple multiple
:multiple-limit="props.multipleLimit"
:reserve-keyword="false" :reserve-keyword="false"
loading-text="..." loading-text="..."
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')" :popper-class="dropdownClasses"
data-test-id="tags-dropdown" data-test-id="tags-dropdown"
@update:model-value="onTagsUpdated" @update:model-value="onTagsUpdated"
@visible-change="onVisibleChange" @visible-change="onVisibleChange"
@remove-tag="onRemoveTag" @remove-tag="onRemoveTag"
> >
<N8nOption <N8nOption
v-if="options.length === 0 && filter" v-if="createEnabled && options.length === 0 && filter"
:key="CREATE_KEY" :key="CREATE_KEY"
ref="createRef" ref="createRef"
:value="CREATE_KEY" :value="CREATE_KEY"
@ -220,7 +239,7 @@ onClickOutside(
</span> </span>
</N8nOption> </N8nOption>
<N8nOption v-else-if="options.length === 0" value="message" disabled> <N8nOption v-else-if="options.length === 0" value="message" disabled>
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span> <span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="allTags.length > 0">{{ <span v-if="allTags.length > 0">{{
i18n.baseText('tagsDropdown.noMatchingTagsExist') i18n.baseText('tagsDropdown.noMatchingTagsExist')
}}</span> }}</span>
@ -237,7 +256,7 @@ onClickOutside(
data-test-id="tag" data-test-id="tag"
/> />
<N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags"> <N8nOption v-if="manageEnabled" :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" /> <font-awesome-icon icon="cog" />
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span> <span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
</N8nOption> </N8nOption>
@ -313,7 +332,7 @@ onClickOutside(
} }
} }
&:after { .tags-dropdown-manage-enabled &:after {
content: ' '; content: ' ';
display: block; display: block;
min-height: $--item-height; min-height: $--item-height;

View file

@ -1,18 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { EditableField } from '../types';
export interface EvaluationHeaderProps { export interface EvaluationHeaderProps {
modelValue: { modelValue: EditableField<string>;
value: string; startEditing: (field: 'name') => void;
isEditing: boolean; saveChanges: (field: 'name') => void;
tempValue: string; handleKeydown: (e: KeyboardEvent, field: 'name') => void;
};
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
handleKeydown: (e: KeyboardEvent, field: string) => void;
} }
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>(); defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
defineProps<EvaluationHeaderProps>(); defineProps<EvaluationHeaderProps>();
const locale = useI18n(); const locale = useI18n();

View file

@ -2,12 +2,14 @@
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus'; import { ElCollapseTransition } from 'element-plus';
import { ref, nextTick } from 'vue'; import { ref, nextTick } from 'vue';
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
interface EvaluationStep { interface EvaluationStep {
title: string; title: string;
warning?: boolean; warning?: boolean;
small?: boolean; small?: boolean;
expanded?: boolean; expanded?: boolean;
tooltip?: string;
} }
const props = withDefaults(defineProps<EvaluationStep>(), { const props = withDefaults(defineProps<EvaluationStep>(), {
@ -15,12 +17,14 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
warning: false, warning: false,
small: false, small: false,
expanded: true, expanded: true,
tooltip: '',
}); });
const locale = useI18n(); const locale = useI18n();
const isExpanded = ref(props.expanded); const isExpanded = ref(props.expanded);
const contentRef = ref<HTMLElement | null>(null); const contentRef = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
const isTooltipVisible = ref(false);
const toggleExpand = async () => { const toggleExpand = async () => {
isExpanded.value = !isExpanded.value; isExpanded.value = !isExpanded.value;
@ -31,11 +35,32 @@ const toggleExpand = async () => {
} }
} }
}; };
const showTooltip = () => {
isTooltipVisible.value = true;
};
const hideTooltip = () => {
isTooltipVisible.value = false;
};
</script> </script>
<template> <template>
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]"> <div
<div :class="$style.content"> ref="containerRef"
:class="[$style.evaluationStep, small && $style.small]"
data-test-id="evaluation-step"
>
<N8nTooltip :disabled="!tooltip" placement="right" :offset="25" :visible="isTooltipVisible">
<template #content>
{{ tooltip }}
</template>
<!-- This empty div is needed to ensure the tooltip trigger area spans the full width of the step.
Without it, the tooltip would only show when hovering over the content div, which is narrower.
The contentPlaceholder creates an invisible full-width area that can trigger the tooltip. -->
<div :class="$style.contentPlaceholder"></div>
</N8nTooltip>
<div :class="$style.content" @mouseenter="showTooltip" @mouseleave="hideTooltip">
<div :class="$style.header"> <div :class="$style.header">
<div :class="[$style.icon, warning && $style.warning]"> <div :class="[$style.icon, warning && $style.warning]">
<slot name="icon" /> <slot name="icon" />
@ -47,6 +72,7 @@ const toggleExpand = async () => {
:class="$style.collapseButton" :class="$style.collapseButton"
:aria-expanded="isExpanded" :aria-expanded="isExpanded"
:aria-controls="'content-' + title.replace(/\s+/g, '-')" :aria-controls="'content-' + title.replace(/\s+/g, '-')"
data-test-id="evaluation-step-collapse-button"
@click="toggleExpand" @click="toggleExpand"
> >
{{ {{
@ -59,7 +85,7 @@ const toggleExpand = async () => {
</div> </div>
<ElCollapseTransition v-if="$slots.cardContent"> <ElCollapseTransition v-if="$slots.cardContent">
<div v-show="isExpanded" :class="$style.cardContentWrapper"> <div v-show="isExpanded" :class="$style.cardContentWrapper">
<div ref="contentRef" :class="$style.cardContent"> <div ref="contentRef" :class="$style.cardContent" data-test-id="evaluation-step-content">
<slot name="cardContent" /> <slot name="cardContent" />
</div> </div>
</div> </div>
@ -85,6 +111,14 @@ const toggleExpand = async () => {
width: 80%; width: 80%;
} }
} }
.contentPlaceholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.icon { .icon {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -1,22 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
export interface MetricsInputProps { export interface MetricsInputProps {
modelValue: string[]; modelValue: Array<Partial<TestMetricRecord>>;
} }
const props = defineProps<MetricsInputProps>(); const props = defineProps<MetricsInputProps>();
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>(); const emit = defineEmits<{
'update:modelValue': [value: MetricsInputProps['modelValue']];
deleteMetric: [metric: Partial<TestMetricRecord>];
}>();
const locale = useI18n(); const locale = useI18n();
function addNewMetric() { function addNewMetric() {
emit('update:modelValue', [...props.modelValue, '']); emit('update:modelValue', [...props.modelValue, { name: '' }]);
} }
function updateMetric(index: number, value: string) { function updateMetric(index: number, name: string) {
const newMetrics = [...props.modelValue]; const newMetrics = [...props.modelValue];
newMetrics[index] = value; newMetrics[index].name = name;
emit('update:modelValue', newMetrics); emit('update:modelValue', newMetrics);
} }
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
emit('deleteMetric', metric);
}
</script> </script>
<template> <template>
@ -27,14 +35,15 @@ function updateMetric(index: number, value: string) {
:class="$style.metricField" :class="$style.metricField"
> >
<div :class="$style.metricsContainer"> <div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index"> <div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput <N8nInput
:ref="`metric_${index}`" :ref="`metric_${index}`"
data-test-id="evaluation-metric-item" data-test-id="evaluation-metric-item"
:model-value="metric" :model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')" :placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)" @update:model-value="(value: string) => updateMetric(index, value)"
/> />
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric)" />
</div> </div>
<n8n-button <n8n-button
type="tertiary" type="tertiary"
@ -54,6 +63,11 @@ function updateMetric(index: number, value: string) {
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.metricItem {
display: flex;
align-items: center;
}
.metricField { .metricField {
width: 100%; width: 100%;
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);

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