mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
Merge branch 'master' into ai-520-impact-of-test-executions-on-concurrency-limits
This commit is contained in:
commit
b5625a07e2
18
codecov.yml
18
codecov.yml
|
@ -32,25 +32,27 @@ component_management:
|
|||
- packages/@n8n/api-types/**
|
||||
- packages/@n8n/config/**
|
||||
- packages/@n8n/client-oauth2/**
|
||||
- packages/@n8n/di/**
|
||||
- packages/@n8n/imap/**
|
||||
- packages/@n8n/permissions/**
|
||||
- packages/@n8n/task-runner/**
|
||||
- packages/n8n-workflow/**
|
||||
- packages/n8n-core/**
|
||||
- packages/n8n-node-dev/**
|
||||
- packages/n8n/**
|
||||
- packages/workflow/**
|
||||
- packages/core/**
|
||||
- packages/cli/**
|
||||
- component_id: frontend_packages
|
||||
name: Frontend
|
||||
paths:
|
||||
- packages/@n8n/chat/**
|
||||
- packages/@n8n/codemirror-lang/**
|
||||
- packages/n8n-design-system/**
|
||||
- packages/n8n-editor-ui/**
|
||||
- packages/design-system/**
|
||||
- packages/editor-ui/**
|
||||
- component_id: nodes_packages
|
||||
name: Nodes
|
||||
paths:
|
||||
- packages/n8n-nodes-base/**
|
||||
- packages/@n8n/n8n-nodes-langchain/**
|
||||
- packages/node-dev/**
|
||||
- packages/nodes-base/**
|
||||
- packages/@n8n/json-schema-to-zod/**
|
||||
- packages/@n8n/nodes-langchain/**
|
||||
|
||||
ignore:
|
||||
- (?s:.*/[^\/]*\.spec\.ts.*)\Z
|
||||
|
|
|
@ -46,7 +46,14 @@ export function getNodes() {
|
|||
}
|
||||
|
||||
export function getNodeByName(name: string) {
|
||||
return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0),
|
||||
() => cy.getByTestId('canvas-node').filter(`[data-node-name="${name}"]`).eq(0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkflowHistoryCloseButton() {
|
||||
return cy.getByTestId('workflow-history-close-button');
|
||||
}
|
||||
|
||||
export function disableNode(name: string) {
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import planData from '../fixtures/Plan_data_opt_in_trial.json';
|
||||
import {
|
||||
BannerStack,
|
||||
MainSidebar,
|
||||
WorkflowPage,
|
||||
visitPublicApiPage,
|
||||
getPublicApiUpgradeCTA,
|
||||
WorkflowsPage,
|
||||
} from '../pages';
|
||||
|
||||
const NUMBER_OF_AI_CREDITS = 100;
|
||||
|
||||
const mainSidebar = new MainSidebar();
|
||||
const bannerStack = new BannerStack();
|
||||
const workflowPage = new WorkflowPage();
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
|
||||
describe('Cloud', () => {
|
||||
before(() => {
|
||||
|
@ -22,6 +24,10 @@ describe('Cloud', () => {
|
|||
cy.overrideSettings({
|
||||
deployment: { type: 'cloud' },
|
||||
n8nMetadata: { userId: '1' },
|
||||
aiCredits: {
|
||||
enabled: true,
|
||||
credits: NUMBER_OF_AI_CREDITS,
|
||||
},
|
||||
});
|
||||
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
|
||||
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
|
||||
|
@ -40,11 +46,11 @@ describe('Cloud', () => {
|
|||
it('should render trial banner for opt-in cloud user', () => {
|
||||
visitWorkflowPage();
|
||||
|
||||
bannerStack.getters.banner().should('be.visible');
|
||||
cy.getByTestId('banner-stack').should('be.visible');
|
||||
|
||||
mainSidebar.actions.signout();
|
||||
|
||||
bannerStack.getters.banner().should('not.be.visible');
|
||||
cy.getByTestId('banner-stack').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -64,4 +70,66 @@ describe('Cloud', () => {
|
|||
getPublicApiUpgradeCTA().should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Easy AI workflow experiment', () => {
|
||||
it('should not show option to take you to the easy AI workflow if experiment is control', () => {
|
||||
window.localStorage.setItem(
|
||||
'N8N_EXPERIMENT_OVERRIDES',
|
||||
JSON.stringify({ '026_easy_ai_workflow': 'control' }),
|
||||
);
|
||||
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
cy.getByTestId('easy-ai-workflow-card').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show option to take you to the easy AI workflow if experiment is variant', () => {
|
||||
window.localStorage.setItem(
|
||||
'N8N_EXPERIMENT_OVERRIDES',
|
||||
JSON.stringify({ '026_easy_ai_workflow': 'variant' }),
|
||||
);
|
||||
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
cy.getByTestId('easy-ai-workflow-card').should('to.exist');
|
||||
});
|
||||
|
||||
it('should show default instructions if free AI credits experiment is control', () => {
|
||||
window.localStorage.setItem(
|
||||
'N8N_EXPERIMENT_OVERRIDES',
|
||||
JSON.stringify({ '027_free_openai_calls': 'control', '026_easy_ai_workflow': 'variant' }),
|
||||
);
|
||||
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
cy.getByTestId('easy-ai-workflow-card').click();
|
||||
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).contains.text('Set up your OpenAI credentials in the OpenAI Model node');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show updated instructions if free AI credits experiment is variant', () => {
|
||||
window.localStorage.setItem(
|
||||
'N8N_EXPERIMENT_OVERRIDES',
|
||||
JSON.stringify({ '027_free_openai_calls': 'variant', '026_easy_ai_workflow': 'variant' }),
|
||||
);
|
||||
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
cy.getByTestId('easy-ai-workflow-card').click();
|
||||
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).contains.text(
|
||||
`Claim your free ${NUMBER_OF_AI_CREDITS} OpenAI calls in the OpenAI model node`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
|
||||
import {
|
||||
CODE_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
IF_NODE_NAME,
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
} from '../constants';
|
||||
import {
|
||||
WorkflowExecutionsTab,
|
||||
WorkflowPage as WorkflowPageClass,
|
||||
WorkflowHistoryPage,
|
||||
} from '../pages';
|
||||
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
const workflowHistoryPage = new WorkflowHistoryPage();
|
||||
|
||||
const createNewWorkflowAndActivate = () => {
|
||||
workflowPage.actions.visit();
|
||||
|
@ -92,7 +88,7 @@ const switchBetweenEditorAndHistory = () => {
|
|||
cy.wait(['@getVersion']);
|
||||
|
||||
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
||||
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
|
||||
getWorkflowHistoryCloseButton().click();
|
||||
cy.wait(['@workflowGet']);
|
||||
cy.wait(1000);
|
||||
|
||||
|
@ -168,7 +164,7 @@ describe('Editor actions should work', () => {
|
|||
cy.wait(['@getVersion']);
|
||||
|
||||
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
||||
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
|
||||
getWorkflowHistoryCloseButton().click();
|
||||
cy.wait(['@workflowGet']);
|
||||
cy.wait(1000);
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import * as formStep from '../composables/setup-template-form-step';
|
|||
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
|
||||
import TestTemplate1 from '../fixtures/Test_Template_1.json';
|
||||
import TestTemplate2 from '../fixtures/Test_Template_2.json';
|
||||
import { clearNotifications } from '../pages/notifications';
|
||||
import {
|
||||
clickUseWorkflowButtonByTitle,
|
||||
visitTemplateCollectionPage,
|
||||
|
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
|
|||
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
||||
|
||||
clearNotifications();
|
||||
|
||||
templateCredentialsSetupPage.finishCredentialSetup();
|
||||
|
||||
workflowPage.getters.canvasNodes().should('have.length', 3);
|
||||
|
||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||
|
||||
// Focus the canvas so the copy to clipboard works
|
||||
workflowPage.getters.canvasNodes().eq(0).realClick();
|
||||
workflowPage.actions.hitSelectAll();
|
||||
workflowPage.actions.hitCopy();
|
||||
|
||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||
// Check workflow JSON by copying it to clipboard
|
||||
cy.readClipboard().then((workflowJSON) => {
|
||||
const workflow = JSON.parse(workflowJSON);
|
||||
|
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
|
|||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
|
||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
|
||||
|
||||
clearNotifications();
|
||||
|
||||
templateCredentialsSetupPage.finishCredentialSetup();
|
||||
|
||||
workflowPage.getters.canvasNodes().should('have.length', 3);
|
||||
|
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
|
|||
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
|
||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
|
||||
|
||||
clearNotifications();
|
||||
|
||||
templateCredentialsSetupPage.finishCredentialSetup();
|
||||
|
||||
getSetupWorkflowCredentialsButton().should('be.visible');
|
||||
|
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
|
|||
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
||||
|
||||
clearNotifications();
|
||||
|
||||
setupCredsModal.closeModalFromContinueButton();
|
||||
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');
|
||||
|
||||
|
|
|
@ -1,22 +1,20 @@
|
|||
import { SettingsPage } from '../pages/settings';
|
||||
|
||||
const settingsPage = new SettingsPage();
|
||||
const url = '/settings';
|
||||
|
||||
describe('Admin user', { disableAutoLogin: true }, () => {
|
||||
it('should see same Settings sub menu items as instance owner', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(settingsPage.url);
|
||||
cy.visit(url);
|
||||
|
||||
let ownerMenuItems = 0;
|
||||
|
||||
settingsPage.getters.menuItems().then(($el) => {
|
||||
cy.getByTestId('menu-item').then(($el) => {
|
||||
ownerMenuItems = $el.length;
|
||||
});
|
||||
|
||||
cy.signout();
|
||||
cy.signinAsAdmin();
|
||||
cy.visit(settingsPage.url);
|
||||
cy.visit(url);
|
||||
|
||||
settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
|
||||
cy.getByTestId('menu-item').should('have.length', ownerMenuItems);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -517,7 +517,7 @@ describe('Node Creator', () => {
|
|||
const actions = [
|
||||
'Get ranked documents from vector store',
|
||||
'Add documents to vector store',
|
||||
'Retrieve documents for AI processing',
|
||||
'Retrieve documents for Chain/Tool as Vector Store',
|
||||
];
|
||||
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
|
|
|
@ -40,7 +40,7 @@ describe('Subworkflow debugging', () => {
|
|||
openNode('Execute Workflow with param');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
|
@ -64,7 +64,7 @@ describe('Subworkflow debugging', () => {
|
|||
openNode('Execute Workflow with param2');
|
||||
|
||||
getOutputPanelItemsCount().should('not.exist');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed but returned same data as input
|
||||
|
@ -109,7 +109,7 @@ describe('Subworkflow debugging', () => {
|
|||
openNode('Execute Workflow with param');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
|
@ -125,7 +125,7 @@ describe('Subworkflow debugging', () => {
|
|||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
|
||||
'include.text',
|
||||
'Inspect Parent Execution',
|
||||
'View parent execution',
|
||||
);
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink()
|
||||
|
|
|
@ -57,7 +57,7 @@ for (const item of $input.all()) {
|
|||
|
||||
return
|
||||
`);
|
||||
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
|
||||
getParameter().get('.cm-lintRange-error').should('have.length', 6);
|
||||
getParameter().contains('itemMatching').realHover();
|
||||
cy.get('.cm-tooltip-lint').should(
|
||||
'have.text',
|
||||
|
@ -81,7 +81,7 @@ $input.item()
|
|||
return []
|
||||
`);
|
||||
|
||||
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
|
||||
getParameter().get('.cm-lintRange-error').should('have.length', 5);
|
||||
getParameter().contains('all').realHover();
|
||||
cy.get('.cm-tooltip-lint').should(
|
||||
'have.text',
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
"value": {},
|
||||
"matchingColumns": [],
|
||||
"schema": [],
|
||||
"ignoreTypeMismatchErrors": false,
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": true
|
||||
},
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class BannerStack extends BasePage {
|
||||
getters = {
|
||||
banner: () => cy.getByTestId('banner-stack'),
|
||||
};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -1,5 +1,13 @@
|
|||
import type { IE2ETestPage } from '../types';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class BasePage implements IE2ETestPage {
|
||||
getters = {};
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class CredentialsPage extends BasePage {
|
||||
url = '/home/credentials';
|
||||
|
||||
|
|
|
@ -8,6 +8,14 @@ const AI_ASSISTANT_FEATURE = {
|
|||
disabledFor: 'control',
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class AIAssistant extends BasePage {
|
||||
url = '/workflows/new';
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from '../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class NodeCreator extends BasePage {
|
||||
url = '/workflow/new';
|
||||
|
||||
|
|
|
@ -7,9 +7,7 @@ export * from './settings-users';
|
|||
export * from './settings-log-streaming';
|
||||
export * from './sidebar';
|
||||
export * from './ndv';
|
||||
export * from './bannerStack';
|
||||
export * from './workflow-executions-tab';
|
||||
export * from './signin';
|
||||
export * from './workflow-history';
|
||||
export * from './workerView';
|
||||
export * from './settings-public-api';
|
||||
|
|
|
@ -3,6 +3,14 @@ import { SigninPage } from './signin';
|
|||
import { WorkflowsPage } from './workflows';
|
||||
import { N8N_AUTH_COOKIE } from '../constants';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class MfaLoginPage extends BasePage {
|
||||
url = '/mfa';
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from './../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class ChangePasswordModal extends BasePage {
|
||||
getters = {
|
||||
modalContainer: () => cy.getByTestId('changePassword-modal').last(),
|
||||
|
|
|
@ -2,6 +2,14 @@ import { getCredentialSaveButton, saveCredential } from '../../composables/modal
|
|||
import { getVisibleSelect } from '../../utils';
|
||||
import { BasePage } from '../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class CredentialsModal extends BasePage {
|
||||
getters = {
|
||||
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
|
||||
|
@ -61,6 +69,7 @@ export class CredentialsModal extends BasePage {
|
|||
this.getters
|
||||
.credentialInputs()
|
||||
.find('input[type=text], input[type=password]')
|
||||
.filter(':not([readonly])')
|
||||
.each(($el) => {
|
||||
cy.wrap($el).type('test');
|
||||
});
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from '../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class MessageBox extends BasePage {
|
||||
getters = {
|
||||
modal: () => cy.get('.el-message-box', { withinSubject: null }),
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from './../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class MfaSetupModal extends BasePage {
|
||||
getters = {
|
||||
modalContainer: () => cy.getByTestId('changePassword-modal').last(),
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from '../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class WorkflowSharingModal extends BasePage {
|
||||
getters = {
|
||||
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { BasePage } from './base';
|
||||
import { getVisiblePopper, getVisibleSelect } from '../utils';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class NDV extends BasePage {
|
||||
getters = {
|
||||
container: () => cy.getByTestId('ndv'),
|
||||
|
|
|
@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
|
|||
* Actions
|
||||
*/
|
||||
export const clearNotifications = () => {
|
||||
successToast().find('.el-notification__closeBtn').click({ multiple: true });
|
||||
const buttons = successToast().find('.el-notification__closeBtn');
|
||||
buttons.then(($buttons) => {
|
||||
if ($buttons.length) {
|
||||
buttons.click({ multiple: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { BasePage } from './base';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class SettingsLogStreamingPage extends BasePage {
|
||||
url = '/settings/log-streaming';
|
||||
|
||||
|
|
|
@ -7,6 +7,14 @@ import { MfaSetupModal } from './modals/mfa-setup-modal';
|
|||
const changePasswordModal = new ChangePasswordModal();
|
||||
const mfaSetupModal = new MfaSetupModal();
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class PersonalSettingsPage extends BasePage {
|
||||
url = '/settings/personal';
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class SettingsUsagePage extends BasePage {
|
||||
url = '/settings/usage';
|
||||
|
||||
getters = {};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -9,6 +9,14 @@ const workflowsPage = new WorkflowsPage();
|
|||
const mainSidebar = new MainSidebar();
|
||||
const settingsSidebar = new SettingsSidebar();
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class SettingsUsersPage extends BasePage {
|
||||
url = '/settings/users';
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class SettingsPage extends BasePage {
|
||||
url = '/settings';
|
||||
|
||||
getters = {
|
||||
menuItems: () => cy.getByTestId('menu-item'),
|
||||
};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -1,6 +1,14 @@
|
|||
import { BasePage } from '../base';
|
||||
import { WorkflowsPage } from '../workflows';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class MainSidebar extends BasePage {
|
||||
getters = {
|
||||
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from '../base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class SettingsSidebar extends BasePage {
|
||||
getters = {
|
||||
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
|
||||
|
|
|
@ -2,6 +2,14 @@ import { BasePage } from './base';
|
|||
import { WorkflowsPage } from './workflows';
|
||||
import { N8N_AUTH_COOKIE } from '../constants';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class SigninPage extends BasePage {
|
||||
url = '/signin';
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class TemplatesPage extends BasePage {
|
||||
url = '/templates';
|
||||
|
||||
|
|
|
@ -2,6 +2,14 @@ import { BasePage } from './base';
|
|||
|
||||
import Chainable = Cypress.Chainable;
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class VariablesPage extends BasePage {
|
||||
url = '/variables';
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class WorkerViewPage extends BasePage {
|
||||
url = '/settings/workers';
|
||||
|
||||
|
|
|
@ -3,6 +3,14 @@ import { WorkflowPage } from './workflow';
|
|||
|
||||
const workflowPage = new WorkflowPage();
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class WorkflowExecutionsTab extends BasePage {
|
||||
getters = {
|
||||
executionsTabButton: () => cy.getByTestId('radio-button-executions'),
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class WorkflowHistoryPage extends BasePage {
|
||||
getters = {
|
||||
workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'),
|
||||
};
|
||||
}
|
|
@ -6,6 +6,15 @@ import { getVisibleSelect } from '../utils';
|
|||
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
|
||||
|
||||
const nodeCreator = new NodeCreator();
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class WorkflowPage extends BasePage {
|
||||
url = '/workflow/new';
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
/**
|
||||
* @deprecated Use functional composables from @composables instead.
|
||||
* If a composable doesn't exist for your use case, please create a new one in:
|
||||
* cypress/composables
|
||||
*
|
||||
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||
* Each getter and action in this class should be moved to individual composable functions.
|
||||
*/
|
||||
export class WorkflowsPage extends BasePage {
|
||||
url = '/home/workflows';
|
||||
|
||||
|
|
|
@ -39,13 +39,23 @@ export class TaskRunnersConfig {
|
|||
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
|
||||
maxOldSpaceSize: string = '';
|
||||
|
||||
/** How many concurrent tasks can a runner execute at a time */
|
||||
/**
|
||||
* How many concurrent tasks can a runner execute at a time
|
||||
*
|
||||
* @note Kept high for backwards compatibility - n8n v2 will reduce this to `5`
|
||||
*/
|
||||
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
||||
maxConcurrency: number = 5;
|
||||
maxConcurrency: number = 10;
|
||||
|
||||
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
||||
/**
|
||||
* How long (in seconds) a task is allowed to take for completion, else the
|
||||
* task will be aborted. (In internal mode, the runner will also be
|
||||
* restarted.) Must be greater than 0.
|
||||
*
|
||||
* @note Kept high for backwards compatibility - n8n v2 will reduce this to `60`
|
||||
*/
|
||||
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
||||
taskTimeout: number = 60;
|
||||
taskTimeout: number = 300; // 5 minutes
|
||||
|
||||
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
||||
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
|
||||
|
|
|
@ -229,8 +229,8 @@ describe('GlobalConfig', () => {
|
|||
maxPayload: 1024 * 1024 * 1024,
|
||||
port: 5679,
|
||||
maxOldSpaceSize: '',
|
||||
maxConcurrency: 5,
|
||||
taskTimeout: 60,
|
||||
maxConcurrency: 10,
|
||||
taskTimeout: 300,
|
||||
heartbeatInterval: 30,
|
||||
},
|
||||
sentry: {
|
||||
|
|
|
@ -131,6 +131,7 @@ export class LmChatGoogleVertex implements INodeType {
|
|||
const credentials = await this.getCredentials('googleApi');
|
||||
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
||||
const email = (credentials.email as string).trim();
|
||||
const region = credentials.region as string;
|
||||
|
||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||
|
||||
|
@ -165,6 +166,7 @@ export class LmChatGoogleVertex implements INodeType {
|
|||
private_key: privateKey,
|
||||
},
|
||||
},
|
||||
location: region,
|
||||
model: modelName,
|
||||
topK: options.topK,
|
||||
topP: options.topP,
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
|
||||
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { VectorStoreZep } from './VectorStoreZep.node';
|
||||
|
||||
describe('VectorStoreZep', () => {
|
||||
const vectorStore = new VectorStoreZep();
|
||||
const helpers = mock<ISupplyDataFunctions['helpers']>();
|
||||
const executeFunctions = mock<ISupplyDataFunctions>({ helpers });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
executeFunctions.addInputData.mockReturnValue({ index: 0 });
|
||||
});
|
||||
|
||||
it('should get vector store cloud client', async () => {
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'mode':
|
||||
return 'retrieve';
|
||||
case 'collectionName':
|
||||
return 'test-collection';
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctions.getCredentials.mockResolvedValue(
|
||||
mock({
|
||||
apiKey: 'some-key',
|
||||
cloud: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response).toBeInstanceOf(ZepCloudVectorStore);
|
||||
});
|
||||
|
||||
it('should get vector store self-hosted client', async () => {
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'mode':
|
||||
return 'retrieve';
|
||||
case 'collectionName':
|
||||
return 'test-collection';
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctions.getCredentials.mockResolvedValue(
|
||||
mock({
|
||||
apiKey: 'some-key',
|
||||
apiUrl: 'https://example.com',
|
||||
cloud: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const { response } = await vectorStore.supplyData.call(executeFunctions, 0);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response).toBeInstanceOf(ZepVectorStore);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import type { IZepConfig } from '@langchain/community/vectorstores/zep';
|
||||
import { ZepVectorStore } from '@langchain/community/vectorstores/zep';
|
||||
import { ZepCloudVectorStore } from '@langchain/community/vectorstores/zep_cloud';
|
||||
import type { IDataObject, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
|
@ -84,17 +84,21 @@ export class VectorStoreZep extends createVectorStoreNode({
|
|||
const credentials = await context.getCredentials<{
|
||||
apiKey?: string;
|
||||
apiUrl: string;
|
||||
cloud: boolean;
|
||||
}>('zepApi');
|
||||
|
||||
const zepConfig: IZepConfig = {
|
||||
apiUrl: credentials.apiUrl,
|
||||
const zepConfig = {
|
||||
apiKey: credentials.apiKey,
|
||||
collectionName,
|
||||
embeddingDimensions: options.embeddingDimensions ?? 1536,
|
||||
metadata: filter,
|
||||
};
|
||||
|
||||
return new ZepVectorStore(embeddings, zepConfig);
|
||||
if (credentials.cloud) {
|
||||
return new ZepCloudVectorStore(embeddings, zepConfig);
|
||||
} else {
|
||||
return new ZepVectorStore(embeddings, { ...zepConfig, apiUrl: credentials.apiUrl });
|
||||
}
|
||||
},
|
||||
async populateVectorStore(context, embeddings, documents, itemIndex) {
|
||||
const collectionName = context.getNodeParameter('collectionName', itemIndex) as string;
|
||||
|
@ -107,10 +111,10 @@ export class VectorStoreZep extends createVectorStoreNode({
|
|||
const credentials = await context.getCredentials<{
|
||||
apiKey?: string;
|
||||
apiUrl: string;
|
||||
cloud: boolean;
|
||||
}>('zepApi');
|
||||
|
||||
const zepConfig = {
|
||||
apiUrl: credentials.apiUrl,
|
||||
apiKey: credentials.apiKey,
|
||||
collectionName,
|
||||
embeddingDimensions: options.embeddingDimensions ?? 1536,
|
||||
|
@ -118,7 +122,14 @@ export class VectorStoreZep extends createVectorStoreNode({
|
|||
};
|
||||
|
||||
try {
|
||||
await ZepVectorStore.fromDocuments(documents, embeddings, zepConfig);
|
||||
if (credentials.cloud) {
|
||||
await ZepCloudVectorStore.fromDocuments(documents, embeddings, zepConfig);
|
||||
} else {
|
||||
await ZepVectorStore.fromDocuments(documents, embeddings, {
|
||||
...zepConfig,
|
||||
apiUrl: credentials.apiUrl,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCode = (error as IDataObject).code as number;
|
||||
const responseData = (error as IDataObject).responseData as string;
|
||||
|
|
|
@ -0,0 +1,234 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
|
||||
{
|
||||
"codex": {
|
||||
"categories": [
|
||||
"AI",
|
||||
],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"subcategories": {
|
||||
"AI": [
|
||||
"Vector Stores",
|
||||
"Tools",
|
||||
"Root Nodes",
|
||||
],
|
||||
"Tools": [
|
||||
"Other Tools",
|
||||
],
|
||||
},
|
||||
},
|
||||
"credentials": undefined,
|
||||
"defaults": {
|
||||
"name": undefined,
|
||||
},
|
||||
"description": undefined,
|
||||
"displayName": undefined,
|
||||
"group": [
|
||||
"transform",
|
||||
],
|
||||
"icon": undefined,
|
||||
"iconColor": undefined,
|
||||
"inputs": "={{
|
||||
((parameters) => {
|
||||
const mode = parameters?.mode;
|
||||
const inputs = [{ displayName: "Embedding", type: "ai_embedding", required: true, maxConnections: 1}]
|
||||
|
||||
if (mode === 'retrieve-as-tool') {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
if (['insert', 'load', 'update'].includes(mode)) {
|
||||
inputs.push({ displayName: "", type: "main"})
|
||||
}
|
||||
|
||||
if (['insert'].includes(mode)) {
|
||||
inputs.push({ displayName: "Document", type: "ai_document", required: true, maxConnections: 1})
|
||||
}
|
||||
return inputs
|
||||
})($parameter)
|
||||
}}",
|
||||
"name": "mockConstructor",
|
||||
"outputs": "={{
|
||||
((parameters) => {
|
||||
const mode = parameters?.mode ?? 'retrieve';
|
||||
|
||||
if (mode === 'retrieve-as-tool') {
|
||||
return [{ displayName: "Tool", type: "ai_tool"}]
|
||||
}
|
||||
|
||||
if (mode === 'retrieve') {
|
||||
return [{ displayName: "Vector Store", type: "ai_vectorStore"}]
|
||||
}
|
||||
return [{ displayName: "", type: "main"}]
|
||||
})($parameter)
|
||||
}}",
|
||||
"properties": [
|
||||
{
|
||||
"default": "retrieve",
|
||||
"displayName": "Operation Mode",
|
||||
"name": "mode",
|
||||
"noDataExpression": true,
|
||||
"options": [
|
||||
{
|
||||
"action": "Get ranked documents from vector store",
|
||||
"description": "Get many ranked documents from vector store for query",
|
||||
"name": "Get Many",
|
||||
"value": "load",
|
||||
},
|
||||
{
|
||||
"action": "Add documents to vector store",
|
||||
"description": "Insert documents into vector store",
|
||||
"name": "Insert Documents",
|
||||
"value": "insert",
|
||||
},
|
||||
{
|
||||
"action": "Retrieve documents for Chain/Tool as Vector Store",
|
||||
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
|
||||
"name": "Retrieve Documents (As Vector Store for Chain/Tool)",
|
||||
"outputConnectionType": "ai_vectorStore",
|
||||
"value": "retrieve",
|
||||
},
|
||||
{
|
||||
"action": "Retrieve documents for AI Agent as Tool",
|
||||
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
|
||||
"name": "Retrieve Documents (As Tool for AI Agent)",
|
||||
"outputConnectionType": "ai_tool",
|
||||
"value": "retrieve-as-tool",
|
||||
},
|
||||
],
|
||||
"type": "options",
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"displayName": "This node must be connected to a vector store retriever. <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='ai_retriever'>Insert one</a>",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"retrieve",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "notice",
|
||||
"type": "notice",
|
||||
"typeOptions": {
|
||||
"containerClass": "ndv-connection-hint-notice",
|
||||
},
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"description": "Name of the vector store",
|
||||
"displayName": "Name",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"retrieve-as-tool",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "toolName",
|
||||
"placeholder": "e.g. company_knowledge_base",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"validateType": "string-alphanumeric",
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"description": "Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often",
|
||||
"displayName": "Description",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"retrieve-as-tool",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "toolDescription",
|
||||
"placeholder": "e.g. undefined",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"typeOptions": {
|
||||
"rows": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"description": "Search prompt to retrieve matching documents from the vector store using similarity-based ranking",
|
||||
"displayName": "Prompt",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"load",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "prompt",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
},
|
||||
{
|
||||
"default": 4,
|
||||
"description": "Number of top results to fetch from vector store",
|
||||
"displayName": "Limit",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"load",
|
||||
"retrieve-as-tool",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "topK",
|
||||
"type": "number",
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"description": "Whether or not to include document metadata",
|
||||
"displayName": "Include Metadata",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"load",
|
||||
"retrieve-as-tool",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "includeDocumentMetadata",
|
||||
"type": "boolean",
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"description": "ID of an embedding entry",
|
||||
"displayName": "ID",
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"update",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
},
|
||||
{
|
||||
"displayOptions": {
|
||||
"show": {
|
||||
"mode": [
|
||||
"load",
|
||||
"retrieve-as-tool",
|
||||
],
|
||||
},
|
||||
},
|
||||
"name": "loadField",
|
||||
},
|
||||
],
|
||||
"version": 1,
|
||||
}
|
||||
`;
|
|
@ -49,7 +49,11 @@ describe('createVectorStoreNode', () => {
|
|||
const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
|
||||
sharedFields: [],
|
||||
insertFields: [],
|
||||
loadFields: [],
|
||||
loadFields: [
|
||||
{
|
||||
name: 'loadField',
|
||||
},
|
||||
],
|
||||
retrieveFields: [],
|
||||
updateFields: [],
|
||||
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
|
||||
|
@ -82,6 +86,7 @@ describe('createVectorStoreNode', () => {
|
|||
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
|
||||
|
||||
// ASSERT
|
||||
expect(nodeType.description).toMatchSnapshot();
|
||||
expect(wrappedVectorStore).toEqual(vectorStore);
|
||||
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -80,10 +80,13 @@ export interface VectorStoreNodeConstructorArgs {
|
|||
) => Promise<VectorStore>;
|
||||
}
|
||||
|
||||
function transformDescriptionForOperationMode(fields: INodeProperties[], mode: NodeOperationMode) {
|
||||
function transformDescriptionForOperationMode(
|
||||
fields: INodeProperties[],
|
||||
mode: NodeOperationMode | NodeOperationMode[],
|
||||
) {
|
||||
return fields.map((field) => ({
|
||||
...field,
|
||||
displayOptions: { show: { mode: [mode] } },
|
||||
displayOptions: { show: { mode: Array.isArray(mode) ? mode : [mode] } },
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -108,17 +111,17 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
|
|||
action: 'Add documents to vector store',
|
||||
},
|
||||
{
|
||||
name: 'Retrieve Documents (As Vector Store for AI Agent)',
|
||||
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
|
||||
value: 'retrieve',
|
||||
description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
|
||||
action: 'Retrieve documents for AI processing as Vector Store',
|
||||
action: 'Retrieve documents for Chain/Tool as Vector Store',
|
||||
outputConnectionType: NodeConnectionType.AiVectorStore,
|
||||
},
|
||||
{
|
||||
name: 'Retrieve Documents (As Tool for AI Agent)',
|
||||
value: 'retrieve-as-tool',
|
||||
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
|
||||
action: 'Retrieve documents for AI processing as Tool',
|
||||
action: 'Retrieve documents for AI Agent as Tool',
|
||||
outputConnectionType: NodeConnectionType.AiTool,
|
||||
},
|
||||
{
|
||||
|
@ -299,7 +302,10 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
},
|
||||
},
|
||||
},
|
||||
...transformDescriptionForOperationMode(args.loadFields ?? [], 'load'),
|
||||
...transformDescriptionForOperationMode(args.loadFields ?? [], [
|
||||
'load',
|
||||
'retrieve-as-tool',
|
||||
]),
|
||||
...transformDescriptionForOperationMode(args.retrieveFields ?? [], 'retrieve'),
|
||||
...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'),
|
||||
],
|
||||
|
|
|
@ -28,6 +28,7 @@ describe('result validation', () => {
|
|||
['binary', {}],
|
||||
['pairedItem', {}],
|
||||
['error', {}],
|
||||
['index', {}], // temporarily allowed until refactored out
|
||||
])(
|
||||
'should not throw an error if the output item has %s key in addition to json',
|
||||
(key, value) => {
|
||||
|
|
|
@ -4,7 +4,19 @@ import type { INodeExecutionData } from 'n8n-workflow';
|
|||
import { ValidationError } from './errors/validation-error';
|
||||
import { isObject } from './obj-utils';
|
||||
|
||||
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']);
|
||||
export const REQUIRED_N8N_ITEM_KEYS = new Set([
|
||||
'json',
|
||||
'binary',
|
||||
'pairedItem',
|
||||
'error',
|
||||
|
||||
/**
|
||||
* The `index` key was added accidentally to Function, FunctionItem, Gong,
|
||||
* Execute Workflow, and ToolWorkflowV2, so we need to allow it temporarily.
|
||||
* Once we stop using it in all nodes, we can stop allowing the `index` key.
|
||||
*/
|
||||
'index',
|
||||
]);
|
||||
|
||||
function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
|
||||
for (const key in item) {
|
||||
|
|
|
@ -195,4 +195,3 @@ export const WsStatusCodes = {
|
|||
} as const;
|
||||
|
||||
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
|
||||
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
|
||||
|
|
|
@ -6,10 +6,11 @@ import {
|
|||
} from '@n8n/api-types';
|
||||
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
||||
import { Response } from 'express';
|
||||
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { WritableStream } from 'node:stream/web';
|
||||
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { Body, Post, RestController } from '@/decorators';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
|
|
|
@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
|
|||
if (credential) {
|
||||
// Decrypt the data if we found the credential with the `credential:update`
|
||||
// scope.
|
||||
decryptedData = this.credentialsService.decrypt(credential);
|
||||
decryptedData = this.credentialsService.decrypt(credential, true);
|
||||
} else {
|
||||
// Otherwise try to find them with only the `credential:read` scope. In
|
||||
// that case we return them without the decrypted data.
|
||||
|
|
|
@ -542,7 +542,7 @@ export class CredentialsService {
|
|||
if (sharing) {
|
||||
// Decrypt the data if we found the credential with the `credential:update`
|
||||
// scope.
|
||||
decryptedData = this.decrypt(sharing.credentials);
|
||||
decryptedData = this.decrypt(sharing.credentials, true);
|
||||
} else {
|
||||
// Otherwise try to find them with only the `credential:read` scope. In
|
||||
// that case we return them without the decrypted data.
|
||||
|
|
|
@ -9,7 +9,8 @@ import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
|
|||
|
||||
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
|
||||
export type MockedNodeItem = {
|
||||
name: string;
|
||||
name?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,4 +35,23 @@ export class TestRun extends WithTimestampsAndStringId {
|
|||
|
||||
@Column(jsonColumnType, { nullable: true })
|
||||
metrics: AggregatedTestRunMetrics;
|
||||
|
||||
/**
|
||||
* Total number of the test cases, matching the filter condition of the test definition (specified annotationTag)
|
||||
*/
|
||||
@Column('integer', { nullable: true })
|
||||
totalCases: number;
|
||||
|
||||
/**
|
||||
* Number of test cases that passed (evaluation workflow was executed successfully)
|
||||
*/
|
||||
@Column('integer', { nullable: true })
|
||||
passedCases: number;
|
||||
|
||||
/**
|
||||
* Number of failed test cases
|
||||
* (any unexpected exception happened during the execution or evaluation workflow ended with an error)
|
||||
*/
|
||||
@Column('integer', { nullable: true })
|
||||
failedCases: number;
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
|||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||
|
||||
export const mysqlMigrations: Migration[] = [
|
||||
InitialMigration1588157391238,
|
||||
|
@ -154,4 +155,5 @@ export const mysqlMigrations: Migration[] = [
|
|||
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||
AddManagedColumnToCredentialsTable1734479635324,
|
||||
AddProjectIcons1729607673469,
|
||||
AddStatsColumnsToTestRun1736172058779,
|
||||
];
|
||||
|
|
|
@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
|||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
InitialMigration1587669153312,
|
||||
|
@ -154,4 +155,5 @@ export const postgresMigrations: Migration[] = [
|
|||
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||
AddManagedColumnToCredentialsTable1734479635324,
|
||||
AddProjectIcons1729607673469,
|
||||
AddStatsColumnsToTestRun1736172058779,
|
||||
];
|
||||
|
|
|
@ -73,6 +73,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
|||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
|
@ -148,6 +149,7 @@ const sqliteMigrations: Migration[] = [
|
|||
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||
AddManagedColumnToCredentialsTable1734479635324,
|
||||
AddProjectIcons1729607673469,
|
||||
AddStatsColumnsToTestRun1736172058779,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
@ -21,14 +21,28 @@ export class TestRunRepository extends Repository<TestRun> {
|
|||
return await this.save(testRun);
|
||||
}
|
||||
|
||||
async markAsRunning(id: string) {
|
||||
return await this.update(id, { status: 'running', runAt: new Date() });
|
||||
async markAsRunning(id: string, totalCases: number) {
|
||||
return await this.update(id, {
|
||||
status: 'running',
|
||||
runAt: new Date(),
|
||||
totalCases,
|
||||
passedCases: 0,
|
||||
failedCases: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
|
||||
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
|
||||
}
|
||||
|
||||
async incrementPassed(id: string) {
|
||||
return await this.increment({ id }, 'passedCases', 1);
|
||||
}
|
||||
|
||||
async incrementFailed(id: string) {
|
||||
return await this.increment({ id }, 'failedCases', 1);
|
||||
}
|
||||
|
||||
async getMany(testDefinitionId: string, options: ListQuery.Options) {
|
||||
const findManyOptions: FindManyOptions<TestRun> = {
|
||||
where: { testDefinition: { id: testDefinitionId } },
|
||||
|
|
|
@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
|
|||
description: z.string().optional(),
|
||||
evaluationWorkflowId: z.string().min(1).optional(),
|
||||
annotationTagId: z.string().min(1).optional(),
|
||||
mockedNodes: z.array(z.object({ name: z.string() })).optional(),
|
||||
mockedNodes: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
|
|
@ -121,13 +121,26 @@ export class TestDefinitionService {
|
|||
relations: ['workflow'],
|
||||
});
|
||||
|
||||
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name));
|
||||
const existingNodeNames = new Map(
|
||||
existingTestDefinition.workflow.nodes.map((n) => [n.name, n]),
|
||||
);
|
||||
const existingNodeIds = new Map(existingTestDefinition.workflow.nodes.map((n) => [n.id, n]));
|
||||
|
||||
attrs.mockedNodes.forEach((node) => {
|
||||
if (!existingNodeNames.has(node.name)) {
|
||||
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
|
||||
if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(node.name))) {
|
||||
throw new BadRequestError(
|
||||
`Pinned node not found in the workflow: ${node.id} (${node.name})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the node names OR node ids if they are not provided
|
||||
attrs.mockedNodes = attrs.mockedNodes.map((node) => {
|
||||
return {
|
||||
id: node.id ?? (node.name && existingNodeNames.get(node.name)?.id),
|
||||
name: node.name ?? (node.id && existingNodeIds.get(node.id)?.name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Update the test definition
|
||||
|
|
|
@ -7,13 +7,24 @@ const wfUnderTestJson = JSON.parse(
|
|||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const wfUnderTestRenamedNodesJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
|
||||
const executionDataJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
describe('createPinData', () => {
|
||||
test('should create pin data from past execution data', () => {
|
||||
const mockedNodes = ['When clicking ‘Test workflow’'].map((name) => ({ name }));
|
||||
const mockedNodes = [
|
||||
{
|
||||
id: '72256d90-3a67-4e29-b032-47df4e5768af',
|
||||
name: 'When clicking ‘Test workflow’',
|
||||
},
|
||||
];
|
||||
|
||||
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
||||
|
||||
|
@ -25,7 +36,7 @@ describe('createPinData', () => {
|
|||
});
|
||||
|
||||
test('should not create pin data for non-existing mocked nodes', () => {
|
||||
const mockedNodes = ['Non-existing node'].map((name) => ({ name }));
|
||||
const mockedNodes = ['non-existing-ID'].map((id) => ({ id }));
|
||||
|
||||
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
||||
|
||||
|
@ -33,9 +44,17 @@ describe('createPinData', () => {
|
|||
});
|
||||
|
||||
test('should create pin data for all mocked nodes', () => {
|
||||
const mockedNodes = ['When clicking ‘Test workflow’', 'Edit Fields', 'Code'].map((name) => ({
|
||||
name,
|
||||
}));
|
||||
const mockedNodes = [
|
||||
{
|
||||
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'When clicking ‘Test workflow’'
|
||||
},
|
||||
{
|
||||
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Edit Fields'
|
||||
},
|
||||
{
|
||||
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
|
||||
},
|
||||
];
|
||||
|
||||
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
||||
|
||||
|
@ -53,4 +72,33 @@ describe('createPinData', () => {
|
|||
|
||||
expect(pinData).toEqual({});
|
||||
});
|
||||
|
||||
test('should create pin data for all mocked nodes with renamed nodes', () => {
|
||||
const mockedNodes = [
|
||||
{
|
||||
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'Manual Run'
|
||||
},
|
||||
{
|
||||
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Set Attribute'
|
||||
},
|
||||
{
|
||||
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
|
||||
},
|
||||
];
|
||||
|
||||
const pinData = createPinData(
|
||||
wfUnderTestRenamedNodesJson,
|
||||
mockedNodes,
|
||||
executionDataJson,
|
||||
wfUnderTestJson, // Pass original workflow JSON as pastWorkflowData
|
||||
);
|
||||
|
||||
expect(pinData).toEqual(
|
||||
expect.objectContaining({
|
||||
'Manual Run': expect.anything(),
|
||||
'Set Attribute': expect.anything(),
|
||||
Code: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"name": "Workflow Under Test",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [-80, 0],
|
||||
"id": "72256d90-3a67-4e29-b032-47df4e5768af",
|
||||
"name": "Manual Run"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "acfeecbe-443c-4220-b63b-d44d69216902",
|
||||
"name": "foo",
|
||||
"value": "bar",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [140, 0],
|
||||
"id": "319f29bc-1dd4-4122-b223-c584752151a4",
|
||||
"name": "Set Attribute"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "for (const item of $input.all()) {\n item.json.random = Math.random();\n}\n\nreturn $input.all();"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [380, 0],
|
||||
"id": "d2474215-63af-40a4-a51e-0ea30d762621",
|
||||
"name": "Code"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Manual Run": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set attribute",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Set attribute": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Wait",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Wait": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Code",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,8 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
|
|||
import { stringify } from 'flatted';
|
||||
import { readFileSync } from 'fs';
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { GenericValue, IRun } from 'n8n-workflow';
|
||||
import type { ErrorReporter } from 'n8n-core';
|
||||
import type { ExecutionError, GenericValue, IRun } from 'n8n-workflow';
|
||||
import path from 'path';
|
||||
|
||||
import type { ActiveExecutions } from '@/active-executions';
|
||||
|
@ -27,6 +28,12 @@ const wfUnderTestJson = JSON.parse(
|
|||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const wfUnderTestRenamedNodesJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
|
||||
const wfEvaluationJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
@ -60,6 +67,7 @@ const executionMocks = [
|
|||
status: 'success',
|
||||
executionData: {
|
||||
data: stringify(executionDataJson),
|
||||
workflowData: wfUnderTestJson,
|
||||
},
|
||||
}),
|
||||
mock<ExecutionEntity>({
|
||||
|
@ -68,6 +76,7 @@ const executionMocks = [
|
|||
status: 'success',
|
||||
executionData: {
|
||||
data: stringify(executionDataJson),
|
||||
workflowData: wfUnderTestRenamedNodesJson,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
@ -82,6 +91,16 @@ function mockExecutionData() {
|
|||
});
|
||||
}
|
||||
|
||||
function mockErrorExecutionData() {
|
||||
return mock<IRun>({
|
||||
data: {
|
||||
resultData: {
|
||||
error: mock<ExecutionError>(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
||||
return mock<IRun>({
|
||||
data: {
|
||||
|
@ -102,6 +121,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
|||
},
|
||||
],
|
||||
},
|
||||
// error is an optional prop, but jest-mock-extended will mock it by default,
|
||||
// which affects the code logic. So, we need to explicitly set it to undefined.
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -148,6 +170,8 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository.createTestRun.mockClear();
|
||||
testRunRepository.markAsRunning.mockClear();
|
||||
testRunRepository.markAsCompleted.mockClear();
|
||||
testRunRepository.incrementFailed.mockClear();
|
||||
testRunRepository.incrementPassed.mockClear();
|
||||
});
|
||||
|
||||
test('should create an instance of TestRunnerService', async () => {
|
||||
|
@ -159,6 +183,7 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||
|
@ -173,6 +198,7 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -210,6 +236,7 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -250,7 +277,7 @@ describe('TestRunnerService', () => {
|
|||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [{ name: 'When clicking ‘Test workflow’' }],
|
||||
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -290,12 +317,185 @@ describe('TestRunnerService', () => {
|
|||
// Check Test Run status was updated correctly
|
||||
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||
metric1: 0.75,
|
||||
metric2: 0,
|
||||
});
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
|
||||
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should properly count passed and failed executions', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
id: 'workflow-under-test-id',
|
||||
...wfUnderTestJson,
|
||||
});
|
||||
|
||||
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||
id: 'evaluation-workflow-id',
|
||||
...wfEvaluationJson,
|
||||
});
|
||||
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||
|
||||
// Mock executions of workflow under test
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
// Mock executions of evaluation workflow
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-2')
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockRejectedValue(new Error('Some error'));
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should properly count failed test executions', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
id: 'workflow-under-test-id',
|
||||
...wfUnderTestJson,
|
||||
});
|
||||
|
||||
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||
id: 'evaluation-workflow-id',
|
||||
...wfEvaluationJson,
|
||||
});
|
||||
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||
|
||||
// Mock executions of workflow under test
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockErrorExecutionData());
|
||||
|
||||
// Mock executions of evaluation workflow
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-2')
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should properly count failed evaluations', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
id: 'workflow-under-test-id',
|
||||
...wfUnderTestJson,
|
||||
});
|
||||
|
||||
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||
id: 'evaluation-workflow-id',
|
||||
...wfEvaluationJson,
|
||||
});
|
||||
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||
|
||||
// Mock executions of workflow under test
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
// Mock executions of evaluation workflow
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-2')
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockResolvedValue(mockErrorExecutionData());
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should specify correct start nodes when running workflow under test', async () => {
|
||||
|
@ -307,6 +507,7 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -347,7 +548,7 @@ describe('TestRunnerService', () => {
|
|||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [{ name: 'When clicking ‘Test workflow’' }],
|
||||
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -380,6 +581,7 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||
|
@ -404,6 +606,7 @@ describe('TestRunnerService', () => {
|
|||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
mock<ErrorReporter>(),
|
||||
);
|
||||
|
||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { parse } from 'flatted';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IRun,
|
||||
IRunData,
|
||||
IRunExecutionData,
|
||||
IWorkflowBase,
|
||||
IWorkflowExecutionDataProcess,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
|
@ -44,6 +46,7 @@ export class TestRunnerService {
|
|||
private readonly testRunRepository: TestRunRepository,
|
||||
private readonly testMetricRepository: TestMetricRepository,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -94,11 +97,17 @@ export class TestRunnerService {
|
|||
private async runTestCase(
|
||||
workflow: WorkflowEntity,
|
||||
pastExecutionData: IRunExecutionData,
|
||||
pastExecutionWorkflowData: IWorkflowBase,
|
||||
mockedNodes: MockedNodeItem[],
|
||||
userId: string,
|
||||
): Promise<IRun | undefined> {
|
||||
// Create pin data from the past execution data
|
||||
const pinData = createPinData(workflow, mockedNodes, pastExecutionData);
|
||||
const pinData = createPinData(
|
||||
workflow,
|
||||
mockedNodes,
|
||||
pastExecutionData,
|
||||
pastExecutionWorkflowData,
|
||||
);
|
||||
|
||||
// Prepare the data to run the workflow
|
||||
const data: IWorkflowExecutionDataProcess = {
|
||||
|
@ -127,6 +136,7 @@ export class TestRunnerService {
|
|||
evaluationWorkflow: WorkflowEntity,
|
||||
expectedData: IRunData,
|
||||
actualData: IRunData,
|
||||
testRunId?: string,
|
||||
) {
|
||||
// Prepare the evaluation wf input data.
|
||||
// Provide both the expected data and the actual data
|
||||
|
@ -139,7 +149,13 @@ export class TestRunnerService {
|
|||
|
||||
// Prepare the data to run the evaluation workflow
|
||||
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
|
||||
|
||||
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
|
||||
// So that we can fetch all execution runs for a test run
|
||||
if (testRunId && data.executionData) {
|
||||
data.executionData.resultData.metadata = {
|
||||
testRunId,
|
||||
};
|
||||
}
|
||||
data.executionMode = 'evaluation';
|
||||
|
||||
// Trigger the evaluation workflow
|
||||
|
@ -216,51 +232,66 @@ export class TestRunnerService {
|
|||
|
||||
// 2. Run over all the test cases
|
||||
|
||||
await this.testRunRepository.markAsRunning(testRun.id);
|
||||
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
|
||||
|
||||
// Object to collect the results of the evaluation workflow executions
|
||||
const metrics = new EvaluationMetrics(testMetricNames);
|
||||
|
||||
for (const { id: pastExecutionId } of pastExecutions) {
|
||||
// Fetch past execution with data
|
||||
const pastExecution = await this.executionRepository.findOne({
|
||||
where: { id: pastExecutionId },
|
||||
relations: ['executionData', 'metadata'],
|
||||
});
|
||||
assert(pastExecution, 'Execution not found');
|
||||
try {
|
||||
// Fetch past execution with data
|
||||
const pastExecution = await this.executionRepository.findOne({
|
||||
where: { id: pastExecutionId },
|
||||
relations: ['executionData', 'metadata'],
|
||||
});
|
||||
assert(pastExecution, 'Execution not found');
|
||||
|
||||
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
|
||||
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
|
||||
|
||||
// Run the test case and wait for it to finish
|
||||
const testCaseExecution = await this.runTestCase(
|
||||
workflow,
|
||||
executionData,
|
||||
test.mockedNodes,
|
||||
user.id,
|
||||
);
|
||||
// Run the test case and wait for it to finish
|
||||
const testCaseExecution = await this.runTestCase(
|
||||
workflow,
|
||||
executionData,
|
||||
pastExecution.executionData.workflowData,
|
||||
test.mockedNodes,
|
||||
user.id,
|
||||
);
|
||||
|
||||
// In case of a permission check issue, the test case execution will be undefined.
|
||||
// Skip them and continue with the next test case
|
||||
if (!testCaseExecution) {
|
||||
continue;
|
||||
// In case of a permission check issue, the test case execution will be undefined.
|
||||
// Skip them, increment the failed count and continue with the next test case
|
||||
if (!testCaseExecution) {
|
||||
await this.testRunRepository.incrementFailed(testRun.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect the results of the test case execution
|
||||
const testCaseRunData = testCaseExecution.data.resultData.runData;
|
||||
|
||||
// Get the original runData from the test case execution data
|
||||
const originalRunData = executionData.resultData.runData;
|
||||
|
||||
// Run the evaluation workflow with the original and new run data
|
||||
const evalExecution = await this.runTestCaseEvaluation(
|
||||
evaluationWorkflow,
|
||||
originalRunData,
|
||||
testCaseRunData,
|
||||
testRun.id,
|
||||
);
|
||||
assert(evalExecution);
|
||||
|
||||
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
||||
|
||||
if (evalExecution.data.resultData.error) {
|
||||
await this.testRunRepository.incrementFailed(testRun.id);
|
||||
} else {
|
||||
await this.testRunRepository.incrementPassed(testRun.id);
|
||||
}
|
||||
} catch (e) {
|
||||
// In case of an unexpected error, increment the failed count and continue with the next test case
|
||||
await this.testRunRepository.incrementFailed(testRun.id);
|
||||
|
||||
this.errorReporter.error(e);
|
||||
}
|
||||
|
||||
// Collect the results of the test case execution
|
||||
const testCaseRunData = testCaseExecution.data.resultData.runData;
|
||||
|
||||
// Get the original runData from the test case execution data
|
||||
const originalRunData = executionData.resultData.runData;
|
||||
|
||||
// Run the evaluation workflow with the original and new run data
|
||||
const evalExecution = await this.runTestCaseEvaluation(
|
||||
evaluationWorkflow,
|
||||
originalRunData,
|
||||
testCaseRunData,
|
||||
);
|
||||
assert(evalExecution);
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
||||
}
|
||||
|
||||
const aggregatedMetrics = metrics.getAggregatedMetrics();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { IRunExecutionData, IPinData } from 'n8n-workflow';
|
||||
import assert from 'assert';
|
||||
import type { IRunExecutionData, IPinData, IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
|
@ -13,16 +14,33 @@ export function createPinData(
|
|||
workflow: WorkflowEntity,
|
||||
mockedNodes: MockedNodeItem[],
|
||||
executionData: IRunExecutionData,
|
||||
pastWorkflowData?: IWorkflowBase,
|
||||
) {
|
||||
const pinData = {} as IPinData;
|
||||
|
||||
const workflowNodeNames = new Set(workflow.nodes.map((node) => node.name));
|
||||
const workflowNodeIds = new Map(workflow.nodes.map((node) => [node.id, node.name]));
|
||||
|
||||
// If the past workflow data is provided, use it to create a map between node IDs and node names
|
||||
const pastWorkflowNodeIds = new Map<string, string>();
|
||||
if (pastWorkflowData) {
|
||||
for (const node of pastWorkflowData.nodes) {
|
||||
pastWorkflowNodeIds.set(node.id, node.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mockedNode of mockedNodes) {
|
||||
if (workflowNodeNames.has(mockedNode.name)) {
|
||||
const nodeData = executionData.resultData.runData[mockedNode.name];
|
||||
assert(mockedNode.id, 'Mocked node ID is missing');
|
||||
|
||||
const nodeName = workflowNodeIds.get(mockedNode.id);
|
||||
|
||||
// If mocked node is still present in the workflow
|
||||
if (nodeName) {
|
||||
// Try to restore node name from past execution data (it might have been renamed between past execution and up-to-date workflow)
|
||||
const pastNodeName = pastWorkflowNodeIds.get(mockedNode.id) ?? nodeName;
|
||||
const nodeData = executionData.resultData.runData[pastNodeName];
|
||||
|
||||
if (nodeData?.[0]?.data?.main?.[0]) {
|
||||
pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0];
|
||||
pinData[nodeName] = nodeData[0]?.data?.main?.[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@ import type { INode, INodesGraphResult } from 'n8n-workflow';
|
|||
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import type { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
||||
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
|
@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => {
|
|||
const nodeTypes = mock<NodeTypes>();
|
||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||
const credentialsRepository = mock<CredentialsRepository>();
|
||||
const eventService = new EventService();
|
||||
|
||||
let telemetryEventRelay: TelemetryEventRelay;
|
||||
|
@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => {
|
|||
nodeTypes,
|
||||
sharedWorkflowRepository,
|
||||
projectRelationRepository,
|
||||
credentialsRepository,
|
||||
);
|
||||
|
||||
await telemetryEventRelay.init();
|
||||
|
@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => {
|
|||
nodeTypes,
|
||||
sharedWorkflowRepository,
|
||||
projectRelationRepository,
|
||||
credentialsRepository,
|
||||
);
|
||||
// @ts-expect-error Private method
|
||||
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
||||
|
@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => {
|
|||
nodeTypes,
|
||||
sharedWorkflowRepository,
|
||||
projectRelationRepository,
|
||||
credentialsRepository,
|
||||
);
|
||||
// @ts-expect-error Private method
|
||||
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
||||
|
@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => {
|
|||
|
||||
it('should call telemetry.track when manual node execution finished', async () => {
|
||||
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||
credentialsRepository.findOneBy.mockResolvedValue(
|
||||
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
|
||||
);
|
||||
|
||||
const runData = {
|
||||
status: 'error',
|
||||
|
@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => {
|
|||
error_node_id: '1',
|
||||
node_id: '1',
|
||||
node_type: 'n8n-nodes-base.jira',
|
||||
is_managed: false,
|
||||
credential_type: null,
|
||||
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||
}),
|
||||
);
|
||||
|
@ -1498,5 +1509,187 @@ describe('TelemetryEventRelay', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call telemetry.track when manual node execution finished with is_managed and credential_type properties', async () => {
|
||||
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||
credentialsRepository.findOneBy.mockResolvedValue(
|
||||
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
|
||||
);
|
||||
|
||||
const runData = {
|
||||
status: 'error',
|
||||
mode: 'manual',
|
||||
data: {
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||||
},
|
||||
startData: {
|
||||
destinationNode: 'OpenAI',
|
||||
runNodeFilter: ['OpenAI'],
|
||||
},
|
||||
resultData: {
|
||||
runData: {},
|
||||
lastNodeExecuted: 'OpenAI',
|
||||
error: new NodeApiError(
|
||||
{
|
||||
id: '1',
|
||||
typeVersion: 1,
|
||||
name: 'Jira',
|
||||
type: 'n8n-nodes-base.jira',
|
||||
parameters: {},
|
||||
position: [100, 200],
|
||||
},
|
||||
{
|
||||
message: 'Error message',
|
||||
description: 'Incorrect API key provided',
|
||||
httpCode: '401',
|
||||
stack: '',
|
||||
},
|
||||
{
|
||||
message: 'Error message',
|
||||
description: 'Error description',
|
||||
level: 'warning',
|
||||
functionality: 'regular',
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
} as unknown as IRun;
|
||||
|
||||
const nodeGraph: INodesGraphResult = {
|
||||
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
|
||||
nameIndices: {
|
||||
Jira: '1',
|
||||
OpenAI: '1',
|
||||
},
|
||||
} as unknown as INodesGraphResult;
|
||||
|
||||
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
|
||||
|
||||
jest
|
||||
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
|
||||
.mockImplementation(
|
||||
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
|
||||
);
|
||||
|
||||
const event: RelayEventMap['workflow-post-execute'] = {
|
||||
workflow: mockWorkflowBase,
|
||||
executionId: 'execution123',
|
||||
userId: 'user123',
|
||||
runData,
|
||||
};
|
||||
|
||||
eventService.emit('workflow-post-execute', event);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
|
||||
id: 'nhu-l8E4hX',
|
||||
});
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
'Manual node exec finished',
|
||||
expect.objectContaining({
|
||||
webhook_domain: null,
|
||||
user_id: 'user123',
|
||||
workflow_id: 'workflow123',
|
||||
status: 'error',
|
||||
executionStatus: 'error',
|
||||
sharing_role: 'sharee',
|
||||
error_message: 'Error message',
|
||||
error_node_type: 'n8n-nodes-base.jira',
|
||||
error_node_id: '1',
|
||||
node_id: '1',
|
||||
node_type: 'n8n-nodes-base.jira',
|
||||
|
||||
is_managed: true,
|
||||
credential_type: 'openAiApi',
|
||||
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflow_id: 'workflow123',
|
||||
success: false,
|
||||
is_manual: true,
|
||||
execution_mode: 'manual',
|
||||
version_cli: N8N_VERSION,
|
||||
error_message: 'Error message',
|
||||
error_node_type: 'n8n-nodes-base.jira',
|
||||
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||
error_node_id: '1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call telemetry.track when user ran out of free AI credits', async () => {
|
||||
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||
credentialsRepository.findOneBy.mockResolvedValue(
|
||||
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
|
||||
);
|
||||
|
||||
const runData = {
|
||||
status: 'error',
|
||||
mode: 'trigger',
|
||||
data: {
|
||||
startData: {
|
||||
destinationNode: 'OpenAI',
|
||||
runNodeFilter: ['OpenAI'],
|
||||
},
|
||||
executionData: {
|
||||
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||||
},
|
||||
resultData: {
|
||||
runData: {},
|
||||
lastNodeExecuted: 'OpenAI',
|
||||
error: new NodeApiError(
|
||||
{
|
||||
id: '1',
|
||||
typeVersion: 1,
|
||||
name: 'OpenAI',
|
||||
type: 'n8n-nodes-base.openAi',
|
||||
parameters: {},
|
||||
position: [100, 200],
|
||||
},
|
||||
{
|
||||
message: `400 - ${JSON.stringify({
|
||||
error: {
|
||||
message: 'error message',
|
||||
type: 'error_type',
|
||||
code: 200,
|
||||
},
|
||||
})}`,
|
||||
error: {
|
||||
message: 'error message',
|
||||
type: 'error_type',
|
||||
code: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
httpCode: '400',
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
} as unknown as IRun;
|
||||
|
||||
jest
|
||||
.spyOn(TelemetryHelpers, 'userInInstanceRanOutOfFreeAiCredits')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
const event: RelayEventMap['workflow-post-execute'] = {
|
||||
workflow: mockWorkflowBase,
|
||||
executionId: 'execution123',
|
||||
userId: 'user123',
|
||||
runData,
|
||||
};
|
||||
|
||||
eventService.emit('workflow-post-execute', event);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User ran out of free AI credits');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ import { get as pslGet } from 'psl';
|
|||
|
||||
import config from '@/config';
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
|
@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
private readonly nodeTypes: NodeTypes,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
) {
|
||||
super(eventService);
|
||||
}
|
||||
|
@ -632,6 +634,10 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
let nodeGraphResult: INodesGraphResult | null = null;
|
||||
|
||||
if (!telemetryProperties.success && runData?.data.resultData.error) {
|
||||
if (TelemetryHelpers.userInInstanceRanOutOfFreeAiCredits(runData)) {
|
||||
this.telemetry.track('User ran out of free AI credits');
|
||||
}
|
||||
|
||||
telemetryProperties.error_message = runData?.data.resultData.error.message;
|
||||
let errorNodeName =
|
||||
'node' in runData?.data.resultData.error
|
||||
|
@ -693,6 +699,8 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
error_node_id: telemetryProperties.error_node_id as string,
|
||||
webhook_domain: null,
|
||||
sharing_role: userRole,
|
||||
credential_type: null,
|
||||
is_managed: false,
|
||||
};
|
||||
|
||||
if (!manualExecEventProperties.node_graph_string) {
|
||||
|
@ -703,7 +711,18 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
}
|
||||
|
||||
if (runData.data.startData?.destinationNode) {
|
||||
const telemetryPayload = {
|
||||
const credentialsData = TelemetryHelpers.extractLastExecutedNodeCredentialData(runData);
|
||||
if (credentialsData) {
|
||||
manualExecEventProperties.credential_type = credentialsData.credentialType;
|
||||
const credential = await this.credentialsRepository.findOneBy({
|
||||
id: credentialsData.credentialId,
|
||||
});
|
||||
if (credential) {
|
||||
manualExecEventProperties.is_managed = credential.isManaged;
|
||||
}
|
||||
}
|
||||
|
||||
const telemetryPayload: ITelemetryTrackProperties = {
|
||||
...manualExecEventProperties,
|
||||
node_type: TelemetryHelpers.getNodeTypeForName(
|
||||
workflow,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { GlobalConfig } from '@n8n/config';
|
|||
import { Service } from '@n8n/di';
|
||||
import compression from 'compression';
|
||||
import express from 'express';
|
||||
import { rateLimit as expressRateLimit } from 'express-rate-limit';
|
||||
import { Logger } from 'n8n-core';
|
||||
import * as a from 'node:assert/strict';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
@ -147,8 +148,16 @@ export class TaskRunnerServer {
|
|||
}
|
||||
|
||||
private configureRoutes() {
|
||||
const createRateLimiter = () =>
|
||||
expressRateLimit({
|
||||
windowMs: 1000,
|
||||
limit: 5,
|
||||
message: { message: 'Too many requests' },
|
||||
});
|
||||
|
||||
this.app.use(
|
||||
this.upgradeEndpoint,
|
||||
createRateLimiter(),
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.taskRunnerAuthController.authMiddleware,
|
||||
(req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) =>
|
||||
|
@ -158,6 +167,7 @@ export class TaskRunnerServer {
|
|||
const authEndpoint = `${this.getEndpointBasePath()}/auth`;
|
||||
this.app.post(
|
||||
authEndpoint,
|
||||
createRateLimiter(),
|
||||
send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)),
|
||||
);
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Container } from '@n8n/di';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
|
|||
import { In } from '@n8n/typeorm';
|
||||
|
||||
import config from '@/config';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { ProjectRole } from '@/databases/entities/project-relation';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
|
@ -555,6 +556,22 @@ describe('GET /credentials/:id', () => {
|
|||
expect(secondCredential.data).toBeDefined();
|
||||
});
|
||||
|
||||
test('should not redact the data when `includeData:true` is passed', async () => {
|
||||
const credentialService = Container.get(CredentialsService);
|
||||
const redactSpy = jest.spyOn(credentialService, 'redact');
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), {
|
||||
user: owner,
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/credentials/${savedCredential.id}`)
|
||||
.query({ includeData: true });
|
||||
|
||||
validateMainCredentialData(response.body.data);
|
||||
expect(response.body.data.data).toBeDefined();
|
||||
expect(redactSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should retrieve non-owned cred for owner', async () => {
|
||||
const [member1, member2] = await createManyUsers(2, {
|
||||
role: 'global:member',
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
|
|||
import { Credentials } from 'n8n-core';
|
||||
import { randomString } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
|
@ -1272,6 +1273,23 @@ describe('GET /credentials/:id', () => {
|
|||
expect(secondResponse.body.data.data).toBeDefined();
|
||||
});
|
||||
|
||||
test('should not redact the data when `includeData:true` is passed', async () => {
|
||||
const credentialService = Container.get(CredentialsService);
|
||||
const redactSpy = jest.spyOn(credentialService, 'redact');
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), {
|
||||
user: owner,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/credentials/${savedCredential.id}`)
|
||||
.query({ includeData: true });
|
||||
|
||||
validateMainCredentialData(response.body.data);
|
||||
expect(response.body.data.data).toBeDefined();
|
||||
expect(redactSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should retrieve owned cred for member', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), {
|
||||
user: member,
|
||||
|
|
|
@ -405,13 +405,14 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
|
|||
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||
mockedNodes: [
|
||||
{
|
||||
id: 'uuid-1234',
|
||||
name: 'Schedule Trigger',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]);
|
||||
expect(resp.body.data.mockedNodes).toEqual([{ id: 'uuid-1234', name: 'Schedule Trigger' }]);
|
||||
});
|
||||
|
||||
test('should return error if pinned nodes are invalid', async () => {
|
||||
|
|
|
@ -93,7 +93,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
|
|||
const testRunRepository = Container.get(TestRunRepository);
|
||||
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
|
||||
// Mark as running just to make a slight delay between the runs
|
||||
await testRunRepository.markAsRunning(testRun1.id);
|
||||
await testRunRepository.markAsRunning(testRun1.id, 10);
|
||||
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
|
||||
|
||||
// Fetch the first page
|
||||
|
|
|
@ -19,4 +19,26 @@ describe('TaskRunnerServer', () => {
|
|||
await agent.get('/healthz').expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/runners/_ws', () => {
|
||||
it('should return 429 when too many requests are made', async () => {
|
||||
await agent.post('/runners/_ws').send({}).expect(401);
|
||||
await agent.post('/runners/_ws').send({}).expect(401);
|
||||
await agent.post('/runners/_ws').send({}).expect(401);
|
||||
await agent.post('/runners/_ws').send({}).expect(401);
|
||||
await agent.post('/runners/_ws').send({}).expect(401);
|
||||
await agent.post('/runners/_ws').send({}).expect(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/runners/auth', () => {
|
||||
it('should return 429 when too many requests are made', async () => {
|
||||
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(429);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -61,11 +61,7 @@ const validateResourceMapperValue = (
|
|||
});
|
||||
|
||||
if (!validationResult.valid) {
|
||||
if (!resourceMapperField.ignoreTypeMismatchErrors) {
|
||||
return { ...validationResult, fieldName: key };
|
||||
} else {
|
||||
paramValues[key] = resolvedValue;
|
||||
}
|
||||
return { ...validationResult, fieldName: key };
|
||||
} else {
|
||||
// If it's valid, set the casted value
|
||||
paramValues[key] = validationResult.newValue;
|
||||
|
|
|
@ -34,7 +34,11 @@ const classes = computed(() => ({
|
|||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.append" data-test-id="card-append" :class="$style.append">
|
||||
<div
|
||||
v-if="$slots.append"
|
||||
data-test-id="card-append"
|
||||
:class="[$style.append, 'n8n-card-append']"
|
||||
>
|
||||
<slot name="append" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -45,7 +49,7 @@ const classes = computed(() => ({
|
|||
border-radius: var(--border-radius-large);
|
||||
border: var(--border-base);
|
||||
background-color: var(--color-background-xlight);
|
||||
padding: var(--spacing-s);
|
||||
padding: var(--card--padding, var(--spacing-s));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
|
@ -101,5 +105,6 @@ const classes = computed(() => ({
|
|||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
width: var(--card--append--width, unset);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -31,6 +31,10 @@ const props = defineProps({
|
|||
multiple: {
|
||||
type: Boolean,
|
||||
},
|
||||
multipleLimit: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
filterMethod: {
|
||||
type: Function,
|
||||
},
|
||||
|
@ -120,6 +124,7 @@ defineExpose({
|
|||
<ElSelect
|
||||
v-bind="{ ...$props, ...listeners }"
|
||||
ref="innerSelect"
|
||||
:multiple-limit="props.multipleLimit"
|
||||
:model-value="props.modelValue ?? undefined"
|
||||
:size="computedSize"
|
||||
:popper-class="props.popperClass"
|
||||
|
|
|
@ -75,6 +75,38 @@ describe('useDeviceSupport()', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isMobileDevice', () => {
|
||||
it('should be true for iOS user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be true for Android user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be false for non-mobile user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(false);
|
||||
});
|
||||
|
||||
it('should be true for iPad user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be true for iPod user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCtrlKeyPressed()', () => {
|
||||
it('should return true for metaKey press on macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
|
||||
|
|
|
@ -12,12 +12,16 @@ export function useDeviceSupport() {
|
|||
!window.matchMedia('(any-pointer: fine)').matches,
|
||||
);
|
||||
const userAgent = ref(navigator.userAgent.toLowerCase());
|
||||
const isMacOs = ref(
|
||||
userAgent.value.includes('macintosh') ||
|
||||
|
||||
const isIOs = ref(
|
||||
userAgent.value.includes('iphone') ||
|
||||
userAgent.value.includes('ipad') ||
|
||||
userAgent.value.includes('iphone') ||
|
||||
userAgent.value.includes('ipod'),
|
||||
);
|
||||
const isAndroidOs = ref(userAgent.value.includes('android'));
|
||||
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
|
||||
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
|
||||
|
||||
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
|
||||
|
||||
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
|
||||
|
@ -30,7 +34,10 @@ export function useDeviceSupport() {
|
|||
return {
|
||||
userAgent: userAgent.value,
|
||||
isTouchDevice: isTouchDevice.value,
|
||||
isAndroidOs: isAndroidOs.value,
|
||||
isIOs: isIOs.value,
|
||||
isMacOs: isMacOs.value,
|
||||
isMobileDevice: isMobileDevice.value,
|
||||
controlKeyCode: controlKeyCode.value,
|
||||
isCtrlKeyPressed,
|
||||
};
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
|
||||
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
|
||||
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%);
|
||||
--prim-gray-320-alpha-010: hsla(var(--prim-gray-h), 10%, 68%, 0.1);
|
||||
--prim-gray-200: hsl(var(--prim-gray-h), 18%, 80%);
|
||||
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
|
||||
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);
|
||||
|
|
|
@ -139,12 +139,20 @@
|
|||
--color-infobox-examples-border-color: var(--prim-gray-670);
|
||||
|
||||
// Code
|
||||
--color-code-tags-string: var(--prim-color-alt-f-tint-150);
|
||||
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
|
||||
--color-code-tags-keyword: var(--prim-color-alt-g-tint-150);
|
||||
--color-code-tags-operator: var(--prim-color-alt-h);
|
||||
--color-code-tags-variable: var(--prim-color-primary-tint-100);
|
||||
--color-code-tags-definition: var(--prim-color-alt-e);
|
||||
--color-code-tags-string: #9ecbff;
|
||||
--color-code-tags-regex: #9ecbff;
|
||||
--color-code-tags-primitive: #79b8ff;
|
||||
--color-code-tags-keyword: #f97583;
|
||||
--color-code-tags-variable: #79b8ff;
|
||||
--color-code-tags-parameter: #e1e4e8;
|
||||
--color-code-tags-function: #b392f0;
|
||||
--color-code-tags-constant: #79b8ff;
|
||||
--color-code-tags-property: #79b8ff;
|
||||
--color-code-tags-type: #b392f0;
|
||||
--color-code-tags-class: #b392f0;
|
||||
--color-code-tags-heading: #79b8ff;
|
||||
--color-code-tags-invalid: #f97583;
|
||||
--color-code-tags-comment: #6a737d;
|
||||
--color-json-default: var(--prim-color-secondary-tint-200);
|
||||
--color-json-null: var(--color-danger);
|
||||
--color-json-boolean: var(--prim-color-alt-a);
|
||||
|
@ -155,15 +163,18 @@
|
|||
--color-json-brackets-hover: var(--prim-color-alt-e);
|
||||
--color-json-line: var(--prim-gray-200);
|
||||
--color-json-highlight: var(--color-background-base);
|
||||
--color-code-background: var(--prim-gray-800);
|
||||
--color-code-background: var(--prim-gray-820);
|
||||
--color-code-background-readonly: var(--prim-gray-740);
|
||||
--color-code-lineHighlight: var(--prim-gray-740);
|
||||
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
|
||||
--color-code-foreground: var(--prim-gray-70);
|
||||
--color-code-caret: var(--prim-gray-10);
|
||||
--color-code-selection: var(--prim-color-alt-e-alpha-04);
|
||||
--color-code-gutterBackground: var(--prim-gray-670);
|
||||
--color-code-gutterForeground: var(--prim-gray-320);
|
||||
--color-code-tags-comment: var(--prim-gray-200);
|
||||
--color-code-selection: #3392ff44;
|
||||
--color-code-selection-highlight: #17e5e633;
|
||||
--color-code-gutter-background: var(--prim-gray-820);
|
||||
--color-code-gutter-foreground: var(--prim-gray-320);
|
||||
--color-code-gutter-foreground-active: var(--prim-gray-10);
|
||||
--color-code-indentation-marker: var(--prim-gray-740);
|
||||
--color-code-indentation-marker-active: var(--prim-gray-670);
|
||||
--color-line-break: var(--prim-gray-420);
|
||||
--color-code-line-break: var(--prim-color-secondary-tint-100);
|
||||
|
||||
|
|
|
@ -183,12 +183,20 @@
|
|||
--color-infobox-examples-border-color: var(--color-foreground-light);
|
||||
|
||||
// Code
|
||||
--color-code-tags-string: var(--prim-color-alt-f);
|
||||
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
|
||||
--color-code-tags-keyword: var(--prim-color-alt-g);
|
||||
--color-code-tags-operator: var(--prim-color-alt-h);
|
||||
--color-code-tags-variable: var(--prim-color-alt-c-shade-100);
|
||||
--color-code-tags-definition: var(--prim-color-alt-e-shade-150);
|
||||
--color-code-tags-string: #032f62;
|
||||
--color-code-tags-regex: #032f62;
|
||||
--color-code-tags-primitive: #005cc5;
|
||||
--color-code-tags-keyword: #d73a49;
|
||||
--color-code-tags-variable: #005cc5;
|
||||
--color-code-tags-parameter: #24292e;
|
||||
--color-code-tags-function: #6f42c1;
|
||||
--color-code-tags-constant: #005cc5;
|
||||
--color-code-tags-property: #005cc5;
|
||||
--color-code-tags-type: #005cc5;
|
||||
--color-code-tags-class: #6f42c1;
|
||||
--color-code-tags-heading: #005cc5;
|
||||
--color-code-tags-invalid: #cb2431;
|
||||
--color-code-tags-comment: #6a737d;
|
||||
--color-json-default: var(--prim-color-secondary-shade-100);
|
||||
--color-json-null: var(--prim-color-alt-c);
|
||||
--color-json-boolean: var(--prim-color-alt-a);
|
||||
|
@ -201,13 +209,16 @@
|
|||
--color-json-highlight: var(--prim-gray-70);
|
||||
--color-code-background: var(--prim-gray-0);
|
||||
--color-code-background-readonly: var(--prim-gray-40);
|
||||
--color-code-lineHighlight: var(--prim-gray-40);
|
||||
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
|
||||
--color-code-foreground: var(--prim-gray-670);
|
||||
--color-code-caret: var(--prim-gray-420);
|
||||
--color-code-selection: var(--prim-gray-120);
|
||||
--color-code-gutterBackground: var(--prim-gray-0);
|
||||
--color-code-gutterForeground: var(--prim-gray-320);
|
||||
--color-code-tags-comment: var(--prim-gray-420);
|
||||
--color-code-caret: var(--prim-gray-820);
|
||||
--color-code-selection: #0366d625;
|
||||
--color-code-selection-highlight: #34d05840;
|
||||
--color-code-gutter-background: var(--prim-gray-0);
|
||||
--color-code-gutter-foreground: var(--prim-gray-320);
|
||||
--color-code-gutter-foreground-active: var(--prim-gray-670);
|
||||
--color-code-indentation-marker: var(--prim-gray-70);
|
||||
--color-code-indentation-marker-active: var(--prim-gray-200);
|
||||
--color-line-break: var(--prim-gray-320);
|
||||
--color-code-line-break: var(--prim-color-secondary-tint-200);
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
@use './base.scss';
|
||||
@use './pagination.scss';
|
||||
@use './dialog.scss';
|
||||
@use './display.scss';
|
||||
// @use "./autocomplete.scss";
|
||||
@use './dropdown.scss';
|
||||
@use './dropdown-menu.scss';
|
||||
|
|
20
packages/design-system/src/css/mixins/_breakpoints.scss
Normal file
20
packages/design-system/src/css/mixins/_breakpoints.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
@use '../common/var';
|
||||
|
||||
@mixin breakpoint($name) {
|
||||
@if map-has-key(var.$breakpoints-spec, $name) {
|
||||
$query: map-get(var.$breakpoints-spec, $name);
|
||||
$media-query: '';
|
||||
|
||||
@each $key, $value in $query {
|
||||
$media-query: '#{$media-query} and (#{$key}: #{$value})';
|
||||
}
|
||||
|
||||
$media-query: unquote(str-slice($media-query, 6)); // Remove the initial ' and '
|
||||
|
||||
@media screen and #{$media-query} {
|
||||
@content;
|
||||
}
|
||||
} @else {
|
||||
@error "No breakpoint named `#{$name}` found in `$breakpoints-spec`.";
|
||||
}
|
||||
}
|
6
packages/design-system/src/css/mixins/index.scss
Normal file
6
packages/design-system/src/css/mixins/index.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
@forward 'breakpoints';
|
||||
@forward 'button';
|
||||
@forward 'config';
|
||||
@forward 'function';
|
||||
@forward 'mixins';
|
||||
@forward 'utils';
|
|
@ -25,6 +25,7 @@
|
|||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/lint": "^6.8.0",
|
||||
"@codemirror/search": "^6.5.6",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.26.3",
|
||||
"@fontsource/open-sans": "^4.5.0",
|
||||
|
@ -39,6 +40,8 @@
|
|||
"@n8n/codemirror-lang": "workspace:*",
|
||||
"@n8n/codemirror-lang-sql": "^1.0.2",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@replit/codemirror-indentation-markers": "^6.5.3",
|
||||
"@typescript/vfs": "^1.6.0",
|
||||
"@sentry/vue": "catalog:frontend",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
|
@ -52,6 +55,7 @@
|
|||
"change-case": "^5.4.4",
|
||||
"chart.js": "^4.4.0",
|
||||
"codemirror-lang-html-n8n": "^1.0.0",
|
||||
"comlink": "^4.4.1",
|
||||
"dateformat": "^3.0.3",
|
||||
"email-providers": "^2.0.1",
|
||||
"esprima-next": "5.8.4",
|
||||
|
@ -70,6 +74,7 @@
|
|||
"qrcode.vue": "^3.3.4",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"timeago.js": "^4.0.2",
|
||||
"typescript": "^5.5.2",
|
||||
"uuid": "catalog:",
|
||||
"v3-infinite-loading": "^1.2.2",
|
||||
"vue": "catalog:frontend",
|
||||
|
|
|
@ -192,6 +192,8 @@ watch(defaultLocale, (newLocale) => {
|
|||
.header {
|
||||
grid-area: header;
|
||||
z-index: var(--z-index-app-header);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import { makeRestApiRequest, request } from '@/utils/apiUtils';
|
||||
|
||||
export interface TestDefinitionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
|
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
|
|||
description?: string | null;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
annotationTag?: string | null;
|
||||
mockedNodes?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
interface CreateTestDefinitionParams {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
|
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
|
|||
evaluationWorkflowId?: string | null;
|
||||
annotationTagId?: string | null;
|
||||
description?: string | null;
|
||||
mockedNodes?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export interface UpdateTestResponse {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description: string | null;
|
||||
annotationTag: string | null;
|
||||
evaluationWorkflowId: string | null;
|
||||
annotationTagId: string | null;
|
||||
description?: string | null;
|
||||
annotationTag?: string | null;
|
||||
evaluationWorkflowId?: string | null;
|
||||
annotationTagId?: string | null;
|
||||
}
|
||||
|
||||
export interface TestRunRecord {
|
||||
id: string;
|
||||
testDefinitionId: string;
|
||||
status: 'new' | 'running' | 'completed' | 'error';
|
||||
metrics?: Record<string, number>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
runAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
interface GetTestRunParams {
|
||||
testDefinitionId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
interface DeleteTestRunParams {
|
||||
testDefinitionId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
const endpoint = '/evaluation/test-definitions';
|
||||
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
|
||||
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
|
||||
|
||||
export async function getTestDefinitions(context: IRestApiContext) {
|
||||
export async function getTestDefinitions(
|
||||
context: IRestApiContext,
|
||||
params?: { workflowId?: string },
|
||||
) {
|
||||
let url = endpoint;
|
||||
if (params?.workflowId) {
|
||||
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
|
||||
}
|
||||
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
|
||||
context,
|
||||
'GET',
|
||||
endpoint,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTestDefinition(context: IRestApiContext, id: string) {
|
||||
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
|
||||
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
|
||||
}
|
||||
|
||||
export async function createTestDefinition(
|
||||
|
@ -71,3 +107,125 @@ export async function updateTestDefinition(
|
|||
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
|
||||
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
|
||||
}
|
||||
|
||||
// Metrics
|
||||
export interface TestMetricRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
testDefinitionId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateTestMetricParams {
|
||||
testDefinitionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateTestMetricParams {
|
||||
name: string;
|
||||
id: string;
|
||||
testDefinitionId: string;
|
||||
}
|
||||
|
||||
export interface DeleteTestMetricParams {
|
||||
testDefinitionId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||
return await makeRestApiRequest<TestMetricRecord[]>(
|
||||
context,
|
||||
'GET',
|
||||
getMetricsEndpoint(testDefinitionId),
|
||||
);
|
||||
};
|
||||
|
||||
export const getTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
testDefinitionId: string,
|
||||
id: string,
|
||||
) => {
|
||||
return await makeRestApiRequest<TestMetricRecord>(
|
||||
context,
|
||||
'GET',
|
||||
getMetricsEndpoint(testDefinitionId, id),
|
||||
);
|
||||
};
|
||||
|
||||
export const createTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
params: CreateTestMetricParams,
|
||||
) => {
|
||||
return await makeRestApiRequest<TestMetricRecord>(
|
||||
context,
|
||||
'POST',
|
||||
getMetricsEndpoint(params.testDefinitionId),
|
||||
{ name: params.name },
|
||||
);
|
||||
};
|
||||
|
||||
export const updateTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
params: UpdateTestMetricParams,
|
||||
) => {
|
||||
return await makeRestApiRequest<TestMetricRecord>(
|
||||
context,
|
||||
'PATCH',
|
||||
getMetricsEndpoint(params.testDefinitionId, params.id),
|
||||
{ name: params.name },
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteTestMetric = async (
|
||||
context: IRestApiContext,
|
||||
params: DeleteTestMetricParams,
|
||||
) => {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'DELETE',
|
||||
getMetricsEndpoint(params.testDefinitionId, params.id),
|
||||
);
|
||||
};
|
||||
|
||||
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
|
||||
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
|
||||
|
||||
// Get all test runs for a test definition
|
||||
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||
return await makeRestApiRequest<TestRunRecord[]>(
|
||||
context,
|
||||
'GET',
|
||||
getRunsEndpoint(testDefinitionId),
|
||||
);
|
||||
};
|
||||
|
||||
// Get specific test run
|
||||
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
|
||||
return await makeRestApiRequest<TestRunRecord>(
|
||||
context,
|
||||
'GET',
|
||||
getRunsEndpoint(params.testDefinitionId, params.runId),
|
||||
);
|
||||
};
|
||||
|
||||
// Start a new test run
|
||||
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${endpoint}/${testDefinitionId}/run`,
|
||||
headers: { 'push-ref': context.pushRef },
|
||||
});
|
||||
// CLI is returning the response without wrapping it in `data` key
|
||||
return response as { success: boolean };
|
||||
};
|
||||
|
||||
// Delete a test run
|
||||
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
|
||||
return await makeRestApiRequest<{ success: boolean }>(
|
||||
context,
|
||||
'DELETE',
|
||||
getRunsEndpoint(params.testDefinitionId, params.runId),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,32 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import type { Extension, Line } from '@codemirror/state';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||
|
||||
import { CODE_NODE_TYPE } from '@/constants';
|
||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
|
||||
import { useCodeEditor } from '@/composables/useCodeEditor';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import AskAI from './AskAI/AskAI.vue';
|
||||
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
|
||||
import { useCompleter } from './completer';
|
||||
import { CODE_PLACEHOLDERS } from './constants';
|
||||
import { useLinter } from './linter';
|
||||
import { codeNodeEditorTheme } from './theme';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
type Props = {
|
||||
mode: CodeExecutionMode;
|
||||
|
@ -36,6 +28,7 @@ type Props = {
|
|||
language?: CodeNodeEditorLanguage;
|
||||
isReadOnly?: boolean;
|
||||
rows?: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -44,99 +37,57 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
language: 'javaScript',
|
||||
isReadOnly: false,
|
||||
rows: 4,
|
||||
id: crypto.randomUUID(),
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const message = useMessage();
|
||||
const editor = ref(null) as Ref<EditorView | null>;
|
||||
const languageCompartment = ref(new Compartment());
|
||||
const dragAndDropCompartment = ref(new Compartment());
|
||||
const linterCompartment = ref(new Compartment());
|
||||
const isEditorHovered = ref(false);
|
||||
const isEditorFocused = ref(false);
|
||||
const tabs = ref(['code', 'ask-ai']);
|
||||
const activeTab = ref('code');
|
||||
const hasChanges = ref(false);
|
||||
const isLoadingAIResponse = ref(false);
|
||||
const codeNodeEditorRef = ref<HTMLDivElement>();
|
||||
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
|
||||
|
||||
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
|
||||
const { createLinter } = useLinter(() => props.mode, editor);
|
||||
const hasManualChanges = ref(false);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const linter = useLinter(
|
||||
() => props.mode,
|
||||
() => props.language,
|
||||
);
|
||||
const extensions = computed(() => [linter.value]);
|
||||
const placeholder = computed(() => CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '');
|
||||
const dragAndDropEnabled = computed(() => {
|
||||
return !props.isReadOnly;
|
||||
});
|
||||
|
||||
const { highlightLine, readEditorValue, editor } = useCodeEditor({
|
||||
id: props.id,
|
||||
editorRef: codeNodeEditorRef,
|
||||
language: () => props.language,
|
||||
languageParams: () => ({ mode: props.mode }),
|
||||
editorValue: () => props.modelValue,
|
||||
placeholder,
|
||||
extensions,
|
||||
isReadOnly: () => props.isReadOnly,
|
||||
theme: {
|
||||
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||
minHeight: '20vh',
|
||||
rows: props.rows,
|
||||
},
|
||||
onChange: onEditorUpdate,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
|
||||
|
||||
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
|
||||
|
||||
const { isReadOnly, language } = props;
|
||||
const extensions: Extension[] = [
|
||||
...readOnlyEditorExtensions,
|
||||
EditorState.readOnly.of(isReadOnly),
|
||||
EditorView.editable.of(!isReadOnly),
|
||||
codeNodeEditorTheme({
|
||||
isReadOnly,
|
||||
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||
minHeight: '20vh',
|
||||
rows: props.rows,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!isReadOnly) {
|
||||
const linter = createLinter(language);
|
||||
if (linter) {
|
||||
extensions.push(linterCompartment.value.of(linter));
|
||||
}
|
||||
|
||||
extensions.push(
|
||||
...writableEditorExtensions,
|
||||
dragAndDropCompartment.value.of(dragAndDropExtension.value),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
isEditorFocused.value = true;
|
||||
},
|
||||
blur: () => {
|
||||
isEditorFocused.value = false;
|
||||
},
|
||||
}),
|
||||
|
||||
EditorView.updateListener.of((viewUpdate) => {
|
||||
if (!viewUpdate.docChanged) return;
|
||||
|
||||
trackCompletion(viewUpdate);
|
||||
|
||||
const value = editor.value?.state.doc.toString();
|
||||
if (value) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
hasChanges.value = true;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [languageSupport, ...otherExtensions] = languageExtensions.value;
|
||||
extensions.push(languageCompartment.value.of(languageSupport), ...otherExtensions);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: props.modelValue ?? placeholder.value,
|
||||
extensions,
|
||||
});
|
||||
|
||||
editor.value = new EditorView({
|
||||
parent: codeNodeEditorRef.value,
|
||||
state,
|
||||
});
|
||||
|
||||
// empty on first load, default param value
|
||||
if (!props.modelValue) {
|
||||
refreshPlaceholder();
|
||||
emit('update:modelValue', placeholder.value);
|
||||
}
|
||||
});
|
||||
|
@ -150,89 +101,12 @@ const askAiEnabled = computed(() => {
|
|||
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
|
||||
});
|
||||
|
||||
const placeholder = computed(() => {
|
||||
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
|
||||
});
|
||||
|
||||
const dragAndDropEnabled = computed(() => {
|
||||
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
|
||||
});
|
||||
|
||||
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
|
||||
|
||||
// eslint-disable-next-line vue/return-in-computed-property
|
||||
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
|
||||
switch (props.language) {
|
||||
case 'javaScript':
|
||||
return [javascript(), autocompletionExtension('javaScript')];
|
||||
case 'python':
|
||||
return [python(), autocompletionExtension('python')];
|
||||
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
|
||||
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
|
||||
emit('update:modelValue', placeholder.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
const current = editor.value.state.doc.toString();
|
||||
if (current === newValue) {
|
||||
return;
|
||||
}
|
||||
editor.value.dispatch({
|
||||
changes: { from: 0, to: getCurrentEditorContent().length, insert: newValue },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.mode,
|
||||
(_newMode, previousMode: CodeExecutionMode) => {
|
||||
reloadLinter();
|
||||
|
||||
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[props.language]?.[previousMode]) {
|
||||
refreshPlaceholder();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(dragAndDropExtension, (extension) => {
|
||||
editor.value?.dispatch({
|
||||
effects: dragAndDropCompartment.value.reconfigure(extension),
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
|
||||
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[props.mode]) {
|
||||
refreshPlaceholder();
|
||||
}
|
||||
|
||||
const [languageSupport] = languageExtensions.value;
|
||||
editor.value?.dispatch({
|
||||
effects: languageCompartment.value.reconfigure(languageSupport),
|
||||
});
|
||||
reloadLinter();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
askAiEnabled,
|
||||
async (isEnabled) => {
|
||||
if (isEnabled && !props.modelValue) {
|
||||
emit('update:modelValue', placeholder.value);
|
||||
}
|
||||
await nextTick();
|
||||
hasChanges.value = props.modelValue !== placeholder.value;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function getCurrentEditorContent() {
|
||||
return editor.value?.state.doc.toString() ?? '';
|
||||
}
|
||||
|
||||
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
||||
// Confirm dialog if leaving ask-ai tab during loading
|
||||
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
|
||||
|
@ -243,69 +117,28 @@ async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
|||
showCancelButton: true,
|
||||
});
|
||||
|
||||
if (confirmModal === 'confirm') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return confirmModal === 'confirm';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onReplaceCode(code: string) {
|
||||
async function onAiReplaceCode(code: string) {
|
||||
const formattedCode = await format(code, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
editor.value?.dispatch({
|
||||
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
|
||||
});
|
||||
emit('update:modelValue', formattedCode);
|
||||
|
||||
activeTab.value = 'code';
|
||||
hasChanges.value = false;
|
||||
hasManualChanges.value = false;
|
||||
}
|
||||
|
||||
function onMouseOver(event: MouseEvent) {
|
||||
const fromElement = event.relatedTarget as HTMLElement;
|
||||
const containerRef = codeNodeEditorContainerRef.value;
|
||||
|
||||
if (!containerRef?.contains(fromElement)) isEditorHovered.value = true;
|
||||
}
|
||||
|
||||
function onMouseOut(event: MouseEvent) {
|
||||
const fromElement = event.relatedTarget as HTMLElement;
|
||||
const containerRef = codeNodeEditorContainerRef.value;
|
||||
|
||||
if (!containerRef?.contains(fromElement)) isEditorHovered.value = false;
|
||||
}
|
||||
|
||||
function reloadLinter() {
|
||||
if (!editor.value) return;
|
||||
|
||||
const linter = createLinter(props.language);
|
||||
if (linter) {
|
||||
editor.value.dispatch({
|
||||
effects: linterCompartment.value.reconfigure(linter),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPlaceholder() {
|
||||
if (!editor.value) return;
|
||||
|
||||
editor.value.dispatch({
|
||||
changes: { from: 0, to: getCurrentEditorContent().length, insert: placeholder.value },
|
||||
});
|
||||
}
|
||||
|
||||
function getLine(lineNumber: number): Line | null {
|
||||
try {
|
||||
return editor.value?.state.doc.line(lineNumber) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function onEditorUpdate(viewUpdate: ViewUpdate) {
|
||||
trackCompletion(viewUpdate);
|
||||
hasManualChanges.value = true;
|
||||
emit('update:modelValue', readEditorValue());
|
||||
}
|
||||
|
||||
function diffApplied() {
|
||||
|
@ -315,25 +148,6 @@ function diffApplied() {
|
|||
});
|
||||
}
|
||||
|
||||
function highlightLine(lineNumber: number | 'final') {
|
||||
if (!editor.value) return;
|
||||
|
||||
if (lineNumber === 'final') {
|
||||
editor.value.dispatch({
|
||||
selection: { anchor: (props.modelValue ?? getCurrentEditorContent()).length },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const line = getLine(lineNumber);
|
||||
|
||||
if (!line) return;
|
||||
|
||||
editor.value.dispatch({
|
||||
selection: { anchor: line.from },
|
||||
});
|
||||
}
|
||||
|
||||
function trackCompletion(viewUpdate: ViewUpdate) {
|
||||
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
||||
|
||||
|
@ -342,7 +156,7 @@ function trackCompletion(viewUpdate: ViewUpdate) {
|
|||
try {
|
||||
// @ts-expect-error - undocumented fields
|
||||
const { fromA, toB } = viewUpdate?.changedRanges[0];
|
||||
const full = getCurrentEditorContent().slice(fromA, toB);
|
||||
const full = viewUpdate.state.doc.slice(fromA, toB).toString();
|
||||
const lastDotIndex = full.lastIndexOf('.');
|
||||
|
||||
let context = null;
|
||||
|
@ -379,16 +193,19 @@ function onAiLoadEnd() {
|
|||
async function onDrop(value: string, event: MouseEvent) {
|
||||
if (!editor.value) return;
|
||||
|
||||
await dropInCodeEditor(toRaw(editor.value), event, value);
|
||||
const valueToInsert =
|
||||
props.mode === 'runOnceForAllItems'
|
||||
? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()')
|
||||
: value;
|
||||
|
||||
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="codeNodeEditorContainerRef"
|
||||
:class="['code-node-editor', $style['code-node-editor-container'], language]"
|
||||
@mouseover="onMouseOver"
|
||||
@mouseout="onMouseOut"
|
||||
:class="['code-node-editor', $style['code-node-editor-container']]"
|
||||
>
|
||||
<el-tabs
|
||||
v-if="askAiEnabled"
|
||||
|
@ -433,8 +250,8 @@ async function onDrop(value: string, event: MouseEvent) {
|
|||
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
|
||||
<AskAI
|
||||
:key="activeTab"
|
||||
:has-changes="hasChanges"
|
||||
@replace-code="onReplaceCode"
|
||||
:has-changes="hasManualChanges"
|
||||
@replace-code="onAiReplaceCode"
|
||||
@started-loading="onAiLoadStart"
|
||||
@finished-loading="onAiLoadEnd"
|
||||
/>
|
||||
|
|
|
@ -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 },
|
||||
]),
|
||||
),
|
||||
];
|
|
@ -28,7 +28,7 @@ export const AUTOCOMPLETABLE_BUILT_IN_MODULES_JS = [
|
|||
|
||||
export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';
|
||||
|
||||
export const DEFAULT_LINTER_DELAY_IN_MS = 300;
|
||||
export const DEFAULT_LINTER_DELAY_IN_MS = 500;
|
||||
|
||||
/**
|
||||
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { Diagnostic } from '@codemirror/lint';
|
||||
import { linter } from '@codemirror/lint';
|
||||
import { linter as codeMirrorLinter } from '@codemirror/lint';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import * as esprima from 'esprima-next';
|
||||
import type { Node, MemberExpression } from 'estree';
|
||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
import { toValue, type MaybeRefOrGetter } from 'vue';
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import {
|
||||
|
@ -17,17 +17,17 @@ import { walk } from './utils';
|
|||
|
||||
export const useLinter = (
|
||||
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||
editor: MaybeRefOrGetter<EditorView | null>,
|
||||
language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
|
||||
) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
function createLinter(language: CodeNodeEditorLanguage) {
|
||||
switch (language) {
|
||||
const linter = computed(() => {
|
||||
switch (toValue(language)) {
|
||||
case 'javaScript':
|
||||
return linter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
||||
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
function lintSource(editorView: EditorView): Diagnostic[] {
|
||||
const doc = editorView.state.doc.toString();
|
||||
|
@ -38,34 +38,7 @@ export const useLinter = (
|
|||
try {
|
||||
ast = esprima.parseScript(script, { range: true });
|
||||
} catch (syntaxError) {
|
||||
let line;
|
||||
|
||||
try {
|
||||
const lineAtError = editorView.state.doc.line(syntaxError.lineNumber - 1).text;
|
||||
|
||||
// optional chaining operators currently unsupported by esprima-next
|
||||
if (['?.', ']?'].some((operator) => lineAtError.includes(operator))) return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
line = editorView.state.doc.line(syntaxError.lineNumber);
|
||||
|
||||
return [
|
||||
{
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
/**
|
||||
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
|
||||
*/
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (ast === null) return [];
|
||||
|
@ -118,7 +91,7 @@ export const useLinter = (
|
|||
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
const varName = getText(node);
|
||||
const varName = getText(editorView, node);
|
||||
|
||||
if (!varName) return;
|
||||
|
||||
|
@ -250,7 +223,7 @@ export const useLinter = (
|
|||
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
|
||||
const [start, end] = getRange(node.property);
|
||||
|
||||
const method = getText(node.property);
|
||||
const method = getText(editorView, node.property);
|
||||
|
||||
if (!method) return;
|
||||
|
||||
|
@ -444,7 +417,7 @@ export const useLinter = (
|
|||
|
||||
if (shadowStart && start > shadowStart) return; // skip shadow item
|
||||
|
||||
const varName = getText(node);
|
||||
const varName = getText(editorView, node);
|
||||
|
||||
if (!varName) return;
|
||||
|
||||
|
@ -489,7 +462,7 @@ export const useLinter = (
|
|||
!['json', 'binary'].includes(node.property.name);
|
||||
|
||||
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
|
||||
const varName = getText(node);
|
||||
const varName = getText(editorView, node);
|
||||
|
||||
if (!varName) return;
|
||||
|
||||
|
@ -636,19 +609,15 @@ export const useLinter = (
|
|||
// helpers
|
||||
// ----------------------------------
|
||||
|
||||
function getText(node: RangeNode) {
|
||||
const editorValue = toValue(editor);
|
||||
|
||||
if (!editorValue) return null;
|
||||
|
||||
function getText(editorView: EditorView, node: RangeNode) {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
return editorValue.state.doc.toString().slice(start, end);
|
||||
return editorView.state.doc.toString().slice(start, end);
|
||||
}
|
||||
|
||||
function getRange(node: RangeNode) {
|
||||
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
||||
}
|
||||
|
||||
return { createLinter };
|
||||
return linter;
|
||||
};
|
||||
|
|
|
@ -32,16 +32,62 @@ interface ThemeSettings {
|
|||
maxHeight?: string;
|
||||
minHeight?: string;
|
||||
rows?: number;
|
||||
highlightColors?: 'default' | 'html';
|
||||
}
|
||||
|
||||
export const codeNodeEditorTheme = ({
|
||||
isReadOnly,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
rows,
|
||||
highlightColors,
|
||||
}: ThemeSettings) => [
|
||||
const codeEditorSyntaxHighlighting = syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
|
||||
{
|
||||
tag: [
|
||||
tags.deleted,
|
||||
tags.character,
|
||||
tags.macroName,
|
||||
tags.definition(tags.name),
|
||||
tags.definition(tags.variableName),
|
||||
tags.atom,
|
||||
tags.bool,
|
||||
],
|
||||
color: 'var(--color-code-tags-variable)',
|
||||
},
|
||||
{ tag: [tags.name, tags.propertyName], color: 'var(--color-code-tags-property)' },
|
||||
{
|
||||
tag: [tags.processingInstruction, tags.string, tags.inserted, tags.special(tags.string)],
|
||||
color: 'var(--color-code-tags-string)',
|
||||
},
|
||||
{
|
||||
tag: [tags.function(tags.variableName), tags.labelName],
|
||||
color: 'var(--color-code-tags-function)',
|
||||
},
|
||||
{
|
||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||
color: 'var(--color-code-tags-constant)',
|
||||
},
|
||||
{ tag: [tags.className], color: 'var(--color-code-tags-class)' },
|
||||
{
|
||||
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
|
||||
color: 'var(--color-code-tags-primitive)',
|
||||
},
|
||||
{ tag: [tags.typeName], color: 'var(--color-code-tags-type)' },
|
||||
{ tag: [tags.operator, tags.operatorKeyword], color: 'var(--color-code-tags-keyword)' },
|
||||
{
|
||||
tag: [tags.url, tags.escape, tags.regexp, tags.link],
|
||||
color: 'var(--color-code-tags-keyword)',
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment, tags.lineComment], color: 'var(--color-code-tags-comment)' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.link, textDecoration: 'underline' },
|
||||
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-code-tags-heading)' },
|
||||
{ tag: tags.invalid, color: 'var(--color-code-tags-invalid)' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{
|
||||
tag: [tags.derefOperator, tags.special(tags.variableName), tags.variableName, tags.separator],
|
||||
color: 'var(--color-code-foreground)',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: ThemeSettings) => [
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
'font-size': BASE_STYLING.fontSize,
|
||||
|
@ -54,13 +100,17 @@ export const codeNodeEditorTheme = ({
|
|||
'.cm-content': {
|
||||
fontFamily: BASE_STYLING.fontFamily,
|
||||
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
|
||||
lineHeight: 'var(--font-line-height-xloose)',
|
||||
paddingTop: 'var(--spacing-2xs)',
|
||||
paddingBottom: 'var(--spacing-s)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--color-code-caret)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'var(--color-code-selection)',
|
||||
},
|
||||
'&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{
|
||||
background: 'var(--color-code-selection)',
|
||||
},
|
||||
'&.cm-editor': {
|
||||
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
|
||||
borderColor: 'var(--border-color-base)',
|
||||
|
@ -75,13 +125,19 @@ export const codeNodeEditorTheme = ({
|
|||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-code-lineHighlight)',
|
||||
},
|
||||
'.cm-lineNumbers .cm-activeLineGutter': {
|
||||
color: 'var(--color-code-gutter-foreground-active)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: isReadOnly
|
||||
? 'var(--color-code-background-readonly)'
|
||||
: 'var(--color-code-gutterBackground)',
|
||||
color: 'var(--color-code-gutterForeground)',
|
||||
: 'var(--color-code-gutter-background)',
|
||||
color: 'var(--color-code-gutter-foreground)',
|
||||
border: '0',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
borderRightColor: 'var(--border-color-base)',
|
||||
},
|
||||
'.cm-gutterElement': {
|
||||
padding: 0,
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
maxWidth: BASE_STYLING.tooltip.maxWidth,
|
||||
|
@ -92,11 +148,30 @@ export const codeNodeEditorTheme = ({
|
|||
maxHeight: maxHeight ?? '100%',
|
||||
...(isReadOnly
|
||||
? {}
|
||||
: { minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto' }),
|
||||
: {
|
||||
minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto',
|
||||
}),
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 var(--spacing-5xs) 0 var(--spacing-2xs)',
|
||||
},
|
||||
'.cm-gutter,.cm-content': {
|
||||
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
|
||||
},
|
||||
'.cm-foldGutter': {
|
||||
width: '16px',
|
||||
},
|
||||
'.cm-fold-marker': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
},
|
||||
'.cm-activeLineGutter .cm-fold-marker, .cm-gutters:hover .cm-fold-marker': {
|
||||
opacity: 1,
|
||||
},
|
||||
'.cm-diagnosticAction': {
|
||||
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
|
||||
color: 'var(--color-primary)',
|
||||
|
@ -106,103 +181,81 @@ export const codeNodeEditorTheme = ({
|
|||
cursor: BASE_STYLING.diagnosticButton.cursor,
|
||||
},
|
||||
'.cm-diagnostic-error': {
|
||||
backgroundColor: 'var(--color-background-base)',
|
||||
backgroundColor: 'var(--color-infobox-background)',
|
||||
},
|
||||
'.cm-diagnosticText': {
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-base)',
|
||||
},
|
||||
'.cm-diagnosticDocs': {
|
||||
fontSize: 'var(--font-size-2xs)',
|
||||
},
|
||||
'.cm-foldPlaceholder': {
|
||||
color: 'var(--color-text-base)',
|
||||
backgroundColor: 'var(--color-background-base)',
|
||||
border: 'var(--border-base)',
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
background: 'var(--color-code-selection-highlight)',
|
||||
},
|
||||
'.cm-selectionMatch-main': {
|
||||
background: 'var(--color-code-selection-highlight)',
|
||||
},
|
||||
'.cm-matchingBracket': {
|
||||
background: 'var(--color-code-selection)',
|
||||
},
|
||||
'.cm-completionMatchedText': {
|
||||
textDecoration: 'none',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-autocomplete-item-selected)',
|
||||
},
|
||||
'.cm-faded > span': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
'.cm-panel.cm-search': {
|
||||
padding: 'var(--spacing-4xs) var(--spacing-2xs)',
|
||||
},
|
||||
'.cm-panels': {
|
||||
background: 'var(--color-background-light)',
|
||||
color: 'var(--color-text-base)',
|
||||
},
|
||||
'.cm-panels-bottom': {
|
||||
borderTop: 'var(--border-base)',
|
||||
},
|
||||
'.cm-textfield': {
|
||||
color: 'var(--color-text-dark)',
|
||||
background: 'var(--color-foreground-xlight)',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
border: 'var(--border-base)',
|
||||
fontSize: '90%',
|
||||
},
|
||||
'.cm-textfield:focus': {
|
||||
outline: 'none',
|
||||
borderColor: 'var(--color-secondary)',
|
||||
},
|
||||
'.cm-panel button': {
|
||||
color: 'var(--color-text-base)',
|
||||
},
|
||||
'.cm-panel input[type="checkbox"]': {
|
||||
border: 'var(--border-base)',
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-panel input[type="checkbox"]:hover': {
|
||||
border: 'var(--border-base)',
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-panel.cm-search label': {
|
||||
fontSize: '90%',
|
||||
},
|
||||
'.cm-button': {
|
||||
outline: 'none',
|
||||
border: 'var(--border-base)',
|
||||
color: 'var(--color-text-dark)',
|
||||
backgroundColor: 'var(--color-foreground-xlight)',
|
||||
backgroundImage: 'none',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
fontSize: '90%',
|
||||
},
|
||||
}),
|
||||
highlightColors === 'html'
|
||||
? syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
color: '#e06c75',
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
|
||||
{
|
||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||
color: '#d19a66',
|
||||
},
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
color: '#e06c75',
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
color: '#56b6c2',
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
|
||||
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
|
||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
|
||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
|
||||
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
|
||||
]),
|
||||
)
|
||||
: syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{
|
||||
tag: tags.comment,
|
||||
color: 'var(--color-code-tags-comment)',
|
||||
},
|
||||
{
|
||||
tag: [tags.string, tags.special(tags.brace)],
|
||||
color: 'var(--color-code-tags-string)',
|
||||
},
|
||||
{
|
||||
tag: [tags.number, tags.self, tags.bool, tags.null],
|
||||
color: 'var(--color-code-tags-primitive)',
|
||||
},
|
||||
{
|
||||
tag: tags.keyword,
|
||||
color: 'var(--color-code-tags-keyword)',
|
||||
},
|
||||
{
|
||||
tag: tags.operator,
|
||||
color: 'var(--color-code-tags-operator)',
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.variableName,
|
||||
tags.propertyName,
|
||||
tags.attributeName,
|
||||
tags.regexp,
|
||||
tags.className,
|
||||
tags.typeName,
|
||||
],
|
||||
color: 'var(--color-code-tags-variable)',
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.definition(tags.typeName),
|
||||
tags.definition(tags.propertyName),
|
||||
tags.function(tags.variableName),
|
||||
],
|
||||
color: 'var(--color-code-tags-definition)',
|
||||
},
|
||||
]),
|
||||
),
|
||||
codeEditorSyntaxHighlighting,
|
||||
];
|
||||
|
|
|
@ -162,6 +162,7 @@ function moveResource() {
|
|||
<template #append>
|
||||
<div :class="$style.cardActions" @click.stop>
|
||||
<ProjectCardBadge
|
||||
:class="$style.cardBadge"
|
||||
:resource="data"
|
||||
:resource-type="ResourceType.Credential"
|
||||
:resource-type-label="resourceTypeLabel"
|
||||
|
@ -180,9 +181,10 @@ function moveResource() {
|
|||
|
||||
<style lang="scss" module>
|
||||
.cardLink {
|
||||
--card--padding: 0 0 0 var(--spacing-s);
|
||||
|
||||
transition: box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 var(--spacing-s);
|
||||
align-items: stretch;
|
||||
|
||||
&:hover {
|
||||
|
@ -215,4 +217,22 @@ function moveResource() {
|
|||
padding: 0 var(--spacing-s) 0 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
.cardLink {
|
||||
--card--padding: 0 var(--spacing-s) var(--spacing-s);
|
||||
--card--append--width: 100%;
|
||||
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cardBadge {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1106,7 +1106,7 @@ function resetCredentialData(): void {
|
|||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.container" data-test-id="credential-edit-dialog">
|
||||
<div :class="$style.sidebar">
|
||||
<div v-if="!isEditingManagedCredential" :class="$style.sidebar">
|
||||
<n8n-menu
|
||||
mode="tabs"
|
||||
:items="sidebarItems"
|
||||
|
|
|
@ -3,6 +3,8 @@ import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
|
||||
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import type { ICredentialsResponse } from '@/Interface';
|
||||
|
||||
vi.mock('@/permissions', () => ({
|
||||
getResourcePermissions: vi.fn(() => ({
|
||||
|
@ -23,6 +25,10 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
|
|||
},
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
sharing: true,
|
||||
externalSecrets: false,
|
||||
},
|
||||
templates: {
|
||||
host: '',
|
||||
},
|
||||
|
@ -67,4 +73,54 @@ describe('CredentialEdit', () => {
|
|||
});
|
||||
await retry(() => expect(queryByTestId('credential-save-button')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('hides menu item when credential is managed', async () => {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
credentialsStore.state.credentials = {
|
||||
'123': {
|
||||
isManaged: false,
|
||||
} as ICredentialsResponse,
|
||||
};
|
||||
|
||||
const { queryByText } = renderComponent({
|
||||
props: {
|
||||
activeId: '123', // credentialId will be set to this value in edit mode
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(queryByText('Details')).toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Connection')).toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Sharing')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('shows menu item when credential is not managed', async () => {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
credentialsStore.state.credentials = {
|
||||
'123': {
|
||||
isManaged: true,
|
||||
} as ICredentialsResponse,
|
||||
};
|
||||
|
||||
const { queryByText } = renderComponent({
|
||||
props: {
|
||||
activeId: '123', // credentialId will be set to this value in edit mode
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(queryByText('Details')).not.toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Connection')).not.toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Sharing')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -245,7 +245,6 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
|
|||
margin-bottom: 0;
|
||||
|
||||
:global(.el-dialog__body) {
|
||||
background-color: var(--color-expression-editor-modal-background);
|
||||
height: 100%;
|
||||
padding: var(--spacing-s);
|
||||
}
|
||||
|
|
|
@ -7,20 +7,14 @@ import { computed, onMounted, ref, watch } from 'vue';
|
|||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { forceParse } from '@/utils/forceParse';
|
||||
import { completionStatus } from '@codemirror/autocomplete';
|
||||
import { inputTheme } from './theme';
|
||||
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
import {
|
||||
autocompleteKeyMap,
|
||||
enterKeyMap,
|
||||
historyKeyMap,
|
||||
tabKeyMap,
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
|
@ -41,24 +35,7 @@ const emit = defineEmits<{
|
|||
const root = ref<HTMLElement>();
|
||||
const extensions = computed(() => [
|
||||
inputTheme(props.isReadOnly),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...tabKeyMap(),
|
||||
...historyKeyMap,
|
||||
...enterKeyMap,
|
||||
...autocompleteKeyMap,
|
||||
{
|
||||
any: (view, event) => {
|
||||
if (event.key === 'Escape' && completionStatus(view.state) === null) {
|
||||
event.stopPropagation();
|
||||
emit('close');
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
Prec.highest(keymap.of(editorKeymap)),
|
||||
n8nLang(),
|
||||
n8nAutocompletion(),
|
||||
mappingDropCursor(),
|
||||
|
@ -66,7 +43,7 @@ const extensions = computed(() => [
|
|||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||
infoBoxTooltips(),
|
||||
]);
|
||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue