mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into pay-2440-bug-credential-testing-is-not-working
This commit is contained in:
commit
ada03278a2
4
.github/workflows/test-workflows.yml
vendored
4
.github/workflows/test-workflows.yml
vendored
|
@ -17,7 +17,9 @@ jobs:
|
||||||
build:
|
build:
|
||||||
name: Install & Build
|
name: Install & Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
|
if: |
|
||||||
|
(github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')) &&
|
||||||
|
!contains(github.event.pull_request.labels.*.name, 'community')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.1.1
|
- uses: actions/checkout@v4.1.1
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
|
|
|
@ -46,7 +46,14 @@ export function getNodes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeByName(name: string) {
|
export function getNodeByName(name: string) {
|
||||||
return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
|
return cy.ifCanvasVersion(
|
||||||
|
() => cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0),
|
||||||
|
() => cy.getByTestId('canvas-node').filter(`[data-node-name="${name}"]`).eq(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkflowHistoryCloseButton() {
|
||||||
|
return cy.getByTestId('workflow-history-close-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disableNode(name: string) {
|
export function disableNode(name: string) {
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import planData from '../fixtures/Plan_data_opt_in_trial.json';
|
import planData from '../fixtures/Plan_data_opt_in_trial.json';
|
||||||
import {
|
import {
|
||||||
BannerStack,
|
|
||||||
MainSidebar,
|
MainSidebar,
|
||||||
WorkflowPage,
|
WorkflowPage,
|
||||||
visitPublicApiPage,
|
visitPublicApiPage,
|
||||||
getPublicApiUpgradeCTA,
|
getPublicApiUpgradeCTA,
|
||||||
|
WorkflowsPage,
|
||||||
} from '../pages';
|
} from '../pages';
|
||||||
|
|
||||||
|
const NUMBER_OF_AI_CREDITS = 100;
|
||||||
|
|
||||||
const mainSidebar = new MainSidebar();
|
const mainSidebar = new MainSidebar();
|
||||||
const bannerStack = new BannerStack();
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
|
const workflowsPage = new WorkflowsPage();
|
||||||
|
|
||||||
describe('Cloud', () => {
|
describe('Cloud', () => {
|
||||||
before(() => {
|
before(() => {
|
||||||
|
@ -22,6 +24,10 @@ describe('Cloud', () => {
|
||||||
cy.overrideSettings({
|
cy.overrideSettings({
|
||||||
deployment: { type: 'cloud' },
|
deployment: { type: 'cloud' },
|
||||||
n8nMetadata: { userId: '1' },
|
n8nMetadata: { userId: '1' },
|
||||||
|
aiCredits: {
|
||||||
|
enabled: true,
|
||||||
|
credits: NUMBER_OF_AI_CREDITS,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
|
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
|
||||||
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
|
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
|
||||||
|
@ -40,11 +46,11 @@ describe('Cloud', () => {
|
||||||
it('should render trial banner for opt-in cloud user', () => {
|
it('should render trial banner for opt-in cloud user', () => {
|
||||||
visitWorkflowPage();
|
visitWorkflowPage();
|
||||||
|
|
||||||
bannerStack.getters.banner().should('be.visible');
|
cy.getByTestId('banner-stack').should('be.visible');
|
||||||
|
|
||||||
mainSidebar.actions.signout();
|
mainSidebar.actions.signout();
|
||||||
|
|
||||||
bannerStack.getters.banner().should('not.be.visible');
|
cy.getByTestId('banner-stack').should('not.be.visible');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,4 +70,66 @@ describe('Cloud', () => {
|
||||||
getPublicApiUpgradeCTA().should('be.visible');
|
getPublicApiUpgradeCTA().should('be.visible');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Easy AI workflow experiment', () => {
|
||||||
|
it('should not show option to take you to the easy AI workflow if experiment is control', () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'N8N_EXPERIMENT_OVERRIDES',
|
||||||
|
JSON.stringify({ '026_easy_ai_workflow': 'control' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
cy.getByTestId('easy-ai-workflow-card').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show option to take you to the easy AI workflow if experiment is variant', () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'N8N_EXPERIMENT_OVERRIDES',
|
||||||
|
JSON.stringify({ '026_easy_ai_workflow': 'variant' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
cy.getByTestId('easy-ai-workflow-card').should('to.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show default instructions if free AI credits experiment is control', () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'N8N_EXPERIMENT_OVERRIDES',
|
||||||
|
JSON.stringify({ '027_free_openai_calls': 'control', '026_easy_ai_workflow': 'variant' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
cy.getByTestId('easy-ai-workflow-card').click();
|
||||||
|
|
||||||
|
workflowPage.getters
|
||||||
|
.stickies()
|
||||||
|
.eq(0)
|
||||||
|
.should(($el) => {
|
||||||
|
expect($el).contains.text('Set up your OpenAI credentials in the OpenAI Model node');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show updated instructions if free AI credits experiment is variant', () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'N8N_EXPERIMENT_OVERRIDES',
|
||||||
|
JSON.stringify({ '027_free_openai_calls': 'variant', '026_easy_ai_workflow': 'variant' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
cy.getByTestId('easy-ai-workflow-card').click();
|
||||||
|
|
||||||
|
workflowPage.getters
|
||||||
|
.stickies()
|
||||||
|
.eq(0)
|
||||||
|
.should(($el) => {
|
||||||
|
expect($el).contains.text(
|
||||||
|
`Claim your free ${NUMBER_OF_AI_CREDITS} OpenAI calls in the OpenAI model node`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
|
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
|
||||||
import {
|
import {
|
||||||
CODE_NODE_NAME,
|
CODE_NODE_NAME,
|
||||||
EDIT_FIELDS_SET_NODE_NAME,
|
EDIT_FIELDS_SET_NODE_NAME,
|
||||||
IF_NODE_NAME,
|
IF_NODE_NAME,
|
||||||
SCHEDULE_TRIGGER_NODE_NAME,
|
SCHEDULE_TRIGGER_NODE_NAME,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import {
|
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||||
WorkflowExecutionsTab,
|
|
||||||
WorkflowPage as WorkflowPageClass,
|
|
||||||
WorkflowHistoryPage,
|
|
||||||
} from '../pages';
|
|
||||||
|
|
||||||
const workflowPage = new WorkflowPageClass();
|
const workflowPage = new WorkflowPageClass();
|
||||||
const executionsTab = new WorkflowExecutionsTab();
|
const executionsTab = new WorkflowExecutionsTab();
|
||||||
const workflowHistoryPage = new WorkflowHistoryPage();
|
|
||||||
|
|
||||||
const createNewWorkflowAndActivate = () => {
|
const createNewWorkflowAndActivate = () => {
|
||||||
workflowPage.actions.visit();
|
workflowPage.actions.visit();
|
||||||
|
@ -92,7 +88,7 @@ const switchBetweenEditorAndHistory = () => {
|
||||||
cy.wait(['@getVersion']);
|
cy.wait(['@getVersion']);
|
||||||
|
|
||||||
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
||||||
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
|
getWorkflowHistoryCloseButton().click();
|
||||||
cy.wait(['@workflowGet']);
|
cy.wait(['@workflowGet']);
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
|
||||||
|
@ -168,7 +164,7 @@ describe('Editor actions should work', () => {
|
||||||
cy.wait(['@getVersion']);
|
cy.wait(['@getVersion']);
|
||||||
|
|
||||||
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
||||||
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
|
getWorkflowHistoryCloseButton().click();
|
||||||
cy.wait(['@workflowGet']);
|
cy.wait(['@workflowGet']);
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import * as formStep from '../composables/setup-template-form-step';
|
||||||
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
|
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
|
||||||
import TestTemplate1 from '../fixtures/Test_Template_1.json';
|
import TestTemplate1 from '../fixtures/Test_Template_1.json';
|
||||||
import TestTemplate2 from '../fixtures/Test_Template_2.json';
|
import TestTemplate2 from '../fixtures/Test_Template_2.json';
|
||||||
|
import { clearNotifications } from '../pages/notifications';
|
||||||
import {
|
import {
|
||||||
clickUseWorkflowButtonByTitle,
|
clickUseWorkflowButtonByTitle,
|
||||||
visitTemplateCollectionPage,
|
visitTemplateCollectionPage,
|
||||||
|
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
templateCredentialsSetupPage.finishCredentialSetup();
|
templateCredentialsSetupPage.finishCredentialSetup();
|
||||||
|
|
||||||
workflowPage.getters.canvasNodes().should('have.length', 3);
|
workflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
|
|
||||||
|
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||||
|
|
||||||
// Focus the canvas so the copy to clipboard works
|
// Focus the canvas so the copy to clipboard works
|
||||||
workflowPage.getters.canvasNodes().eq(0).realClick();
|
workflowPage.getters.canvasNodes().eq(0).realClick();
|
||||||
workflowPage.actions.hitSelectAll();
|
workflowPage.actions.hitSelectAll();
|
||||||
workflowPage.actions.hitCopy();
|
workflowPage.actions.hitCopy();
|
||||||
|
|
||||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
|
||||||
// Check workflow JSON by copying it to clipboard
|
// Check workflow JSON by copying it to clipboard
|
||||||
cy.readClipboard().then((workflowJSON) => {
|
cy.readClipboard().then((workflowJSON) => {
|
||||||
const workflow = JSON.parse(workflowJSON);
|
const workflow = JSON.parse(workflowJSON);
|
||||||
|
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
templateCredentialsSetupPage.finishCredentialSetup();
|
templateCredentialsSetupPage.finishCredentialSetup();
|
||||||
|
|
||||||
workflowPage.getters.canvasNodes().should('have.length', 3);
|
workflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
|
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
|
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
templateCredentialsSetupPage.finishCredentialSetup();
|
templateCredentialsSetupPage.finishCredentialSetup();
|
||||||
|
|
||||||
getSetupWorkflowCredentialsButton().should('be.visible');
|
getSetupWorkflowCredentialsButton().should('be.visible');
|
||||||
|
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
setupCredsModal.closeModalFromContinueButton();
|
setupCredsModal.closeModalFromContinueButton();
|
||||||
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');
|
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,20 @@
|
||||||
import { SettingsPage } from '../pages/settings';
|
const url = '/settings';
|
||||||
|
|
||||||
const settingsPage = new SettingsPage();
|
|
||||||
|
|
||||||
describe('Admin user', { disableAutoLogin: true }, () => {
|
describe('Admin user', { disableAutoLogin: true }, () => {
|
||||||
it('should see same Settings sub menu items as instance owner', () => {
|
it('should see same Settings sub menu items as instance owner', () => {
|
||||||
cy.signinAsOwner();
|
cy.signinAsOwner();
|
||||||
cy.visit(settingsPage.url);
|
cy.visit(url);
|
||||||
|
|
||||||
let ownerMenuItems = 0;
|
let ownerMenuItems = 0;
|
||||||
|
|
||||||
settingsPage.getters.menuItems().then(($el) => {
|
cy.getByTestId('menu-item').then(($el) => {
|
||||||
ownerMenuItems = $el.length;
|
ownerMenuItems = $el.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.signout();
|
cy.signout();
|
||||||
cy.signinAsAdmin();
|
cy.signinAsAdmin();
|
||||||
cy.visit(settingsPage.url);
|
cy.visit(url);
|
||||||
|
|
||||||
settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
|
cy.getByTestId('menu-item').should('have.length', ownerMenuItems);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -517,7 +517,7 @@ describe('Node Creator', () => {
|
||||||
const actions = [
|
const actions = [
|
||||||
'Get ranked documents from vector store',
|
'Get ranked documents from vector store',
|
||||||
'Add documents to vector store',
|
'Add documents to vector store',
|
||||||
'Retrieve documents for AI processing',
|
'Retrieve documents for Chain/Tool as Vector Store',
|
||||||
];
|
];
|
||||||
|
|
||||||
nodeCreatorFeature.actions.openNodeCreator();
|
nodeCreatorFeature.actions.openNodeCreator();
|
||||||
|
|
|
@ -40,7 +40,7 @@ describe('Subworkflow debugging', () => {
|
||||||
openNode('Execute Workflow with param');
|
openNode('Execute Workflow with param');
|
||||||
|
|
||||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||||
|
|
||||||
// ensure workflow executed and waited on output
|
// ensure workflow executed and waited on output
|
||||||
|
@ -64,7 +64,7 @@ describe('Subworkflow debugging', () => {
|
||||||
openNode('Execute Workflow with param2');
|
openNode('Execute Workflow with param2');
|
||||||
|
|
||||||
getOutputPanelItemsCount().should('not.exist');
|
getOutputPanelItemsCount().should('not.exist');
|
||||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||||
|
|
||||||
// ensure workflow executed but returned same data as input
|
// ensure workflow executed but returned same data as input
|
||||||
|
@ -109,7 +109,7 @@ describe('Subworkflow debugging', () => {
|
||||||
openNode('Execute Workflow with param');
|
openNode('Execute Workflow with param');
|
||||||
|
|
||||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
|
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||||
|
|
||||||
// ensure workflow executed and waited on output
|
// ensure workflow executed and waited on output
|
||||||
|
@ -125,7 +125,7 @@ describe('Subworkflow debugging', () => {
|
||||||
|
|
||||||
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
|
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
|
||||||
'include.text',
|
'include.text',
|
||||||
'Inspect Parent Execution',
|
'View parent execution',
|
||||||
);
|
);
|
||||||
|
|
||||||
getExecutionPreviewOutputPanelRelatedExecutionLink()
|
getExecutionPreviewOutputPanelRelatedExecutionLink()
|
||||||
|
|
|
@ -57,7 +57,7 @@ for (const item of $input.all()) {
|
||||||
|
|
||||||
return
|
return
|
||||||
`);
|
`);
|
||||||
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
|
getParameter().get('.cm-lintRange-error').should('have.length', 6);
|
||||||
getParameter().contains('itemMatching').realHover();
|
getParameter().contains('itemMatching').realHover();
|
||||||
cy.get('.cm-tooltip-lint').should(
|
cy.get('.cm-tooltip-lint').should(
|
||||||
'have.text',
|
'have.text',
|
||||||
|
@ -81,7 +81,7 @@ $input.item()
|
||||||
return []
|
return []
|
||||||
`);
|
`);
|
||||||
|
|
||||||
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
|
getParameter().get('.cm-lintRange-error').should('have.length', 5);
|
||||||
getParameter().contains('all').realHover();
|
getParameter().contains('all').realHover();
|
||||||
cy.get('.cm-tooltip-lint').should(
|
cy.get('.cm-tooltip-lint').should(
|
||||||
'have.text',
|
'have.text',
|
||||||
|
|
|
@ -171,9 +171,16 @@ describe('Workflow Actions', () => {
|
||||||
cy.get('#node-creator').should('not.exist');
|
cy.get('#node-creator').should('not.exist');
|
||||||
|
|
||||||
WorkflowPage.actions.hitSelectAll();
|
WorkflowPage.actions.hitSelectAll();
|
||||||
cy.get('.jtk-drag-selected').should('have.length', 2);
|
|
||||||
WorkflowPage.actions.hitCopy();
|
WorkflowPage.actions.hitCopy();
|
||||||
successToast().should('exist');
|
successToast().should('exist');
|
||||||
|
// Both nodes should be copied
|
||||||
|
cy.window()
|
||||||
|
.its('navigator.clipboard')
|
||||||
|
.then((clip) => clip.readText())
|
||||||
|
.then((text) => {
|
||||||
|
const copiedWorkflow = JSON.parse(text);
|
||||||
|
expect(copiedWorkflow.nodes).to.have.length(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should paste nodes (both current and old node versions)', () => {
|
it('should paste nodes (both current and old node versions)', () => {
|
||||||
|
@ -345,7 +352,15 @@ describe('Workflow Actions', () => {
|
||||||
WorkflowPage.actions.hitDeleteAllNodes();
|
WorkflowPage.actions.hitDeleteAllNodes();
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
||||||
// Button should be disabled
|
// Button should be disabled
|
||||||
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
|
cy.ifCanvasVersion(
|
||||||
|
() => {
|
||||||
|
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// In new canvas, button does not exist when there are no nodes
|
||||||
|
WorkflowPage.getters.executeWorkflowButton().should('not.exist');
|
||||||
|
},
|
||||||
|
);
|
||||||
// Keyboard shortcut should not work
|
// Keyboard shortcut should not work
|
||||||
WorkflowPage.actions.hitExecuteWorkflow();
|
WorkflowPage.actions.hitExecuteWorkflow();
|
||||||
successToast().should('not.exist');
|
successToast().should('not.exist');
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
"value": {},
|
"value": {},
|
||||||
"matchingColumns": [],
|
"matchingColumns": [],
|
||||||
"schema": [],
|
"schema": [],
|
||||||
"ignoreTypeMismatchErrors": false,
|
|
||||||
"attemptToConvertTypes": false,
|
"attemptToConvertTypes": false,
|
||||||
"convertFieldsToString": true
|
"convertFieldsToString": true
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
"format:check": "biome ci .",
|
"format:check": "biome ci .",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lintfix": "eslint . --fix",
|
"lintfix": "eslint . --fix",
|
||||||
"develop": "cd ..; pnpm dev",
|
"develop": "cd ..; pnpm dev:e2e:server",
|
||||||
"start": "cd ..; pnpm start"
|
"start": "cd ..; pnpm start"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -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';
|
import type { IE2ETestPage } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class BasePage implements IE2ETestPage {
|
export class BasePage implements IE2ETestPage {
|
||||||
getters = {};
|
getters = {};
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class CredentialsPage extends BasePage {
|
export class CredentialsPage extends BasePage {
|
||||||
url = '/home/credentials';
|
url = '/home/credentials';
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,14 @@ const AI_ASSISTANT_FEATURE = {
|
||||||
disabledFor: 'control',
|
disabledFor: 'control',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class AIAssistant extends BasePage {
|
export class AIAssistant extends BasePage {
|
||||||
url = '/workflows/new';
|
url = '/workflows/new';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class NodeCreator extends BasePage {
|
export class NodeCreator extends BasePage {
|
||||||
url = '/workflow/new';
|
url = '/workflow/new';
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,7 @@ export * from './settings-users';
|
||||||
export * from './settings-log-streaming';
|
export * from './settings-log-streaming';
|
||||||
export * from './sidebar';
|
export * from './sidebar';
|
||||||
export * from './ndv';
|
export * from './ndv';
|
||||||
export * from './bannerStack';
|
|
||||||
export * from './workflow-executions-tab';
|
export * from './workflow-executions-tab';
|
||||||
export * from './signin';
|
export * from './signin';
|
||||||
export * from './workflow-history';
|
|
||||||
export * from './workerView';
|
export * from './workerView';
|
||||||
export * from './settings-public-api';
|
export * from './settings-public-api';
|
||||||
|
|
|
@ -3,6 +3,14 @@ import { SigninPage } from './signin';
|
||||||
import { WorkflowsPage } from './workflows';
|
import { WorkflowsPage } from './workflows';
|
||||||
import { N8N_AUTH_COOKIE } from '../constants';
|
import { N8N_AUTH_COOKIE } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class MfaLoginPage extends BasePage {
|
export class MfaLoginPage extends BasePage {
|
||||||
url = '/mfa';
|
url = '/mfa';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from './../base';
|
import { BasePage } from './../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class ChangePasswordModal extends BasePage {
|
export class ChangePasswordModal extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
modalContainer: () => cy.getByTestId('changePassword-modal').last(),
|
modalContainer: () => cy.getByTestId('changePassword-modal').last(),
|
||||||
|
|
|
@ -2,6 +2,14 @@ import { getCredentialSaveButton, saveCredential } from '../../composables/modal
|
||||||
import { getVisibleSelect } from '../../utils';
|
import { getVisibleSelect } from '../../utils';
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class CredentialsModal extends BasePage {
|
export class CredentialsModal extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
|
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
|
||||||
|
@ -61,6 +69,7 @@ export class CredentialsModal extends BasePage {
|
||||||
this.getters
|
this.getters
|
||||||
.credentialInputs()
|
.credentialInputs()
|
||||||
.find('input[type=text], input[type=password]')
|
.find('input[type=text], input[type=password]')
|
||||||
|
.filter(':not([readonly])')
|
||||||
.each(($el) => {
|
.each(($el) => {
|
||||||
cy.wrap($el).type('test');
|
cy.wrap($el).type('test');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class MessageBox extends BasePage {
|
export class MessageBox extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
modal: () => cy.get('.el-message-box', { withinSubject: null }),
|
modal: () => cy.get('.el-message-box', { withinSubject: null }),
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from './../base';
|
import { BasePage } from './../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class MfaSetupModal extends BasePage {
|
export class MfaSetupModal extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
modalContainer: () => cy.getByTestId('changePassword-modal').last(),
|
modalContainer: () => cy.getByTestId('changePassword-modal').last(),
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class WorkflowSharingModal extends BasePage {
|
export class WorkflowSharingModal extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),
|
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
import { getVisiblePopper, getVisibleSelect } from '../utils';
|
import { getVisiblePopper, getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class NDV extends BasePage {
|
export class NDV extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
container: () => cy.getByTestId('ndv'),
|
container: () => cy.getByTestId('ndv'),
|
||||||
|
|
|
@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
|
||||||
* Actions
|
* Actions
|
||||||
*/
|
*/
|
||||||
export const clearNotifications = () => {
|
export const clearNotifications = () => {
|
||||||
successToast().find('.el-notification__closeBtn').click({ multiple: true });
|
const buttons = successToast().find('.el-notification__closeBtn');
|
||||||
|
buttons.then(($buttons) => {
|
||||||
|
if ($buttons.length) {
|
||||||
|
buttons.click({ multiple: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class SettingsLogStreamingPage extends BasePage {
|
export class SettingsLogStreamingPage extends BasePage {
|
||||||
url = '/settings/log-streaming';
|
url = '/settings/log-streaming';
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,14 @@ import { MfaSetupModal } from './modals/mfa-setup-modal';
|
||||||
const changePasswordModal = new ChangePasswordModal();
|
const changePasswordModal = new ChangePasswordModal();
|
||||||
const mfaSetupModal = new MfaSetupModal();
|
const mfaSetupModal = new MfaSetupModal();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class PersonalSettingsPage extends BasePage {
|
export class PersonalSettingsPage extends BasePage {
|
||||||
url = '/settings/personal';
|
url = '/settings/personal';
|
||||||
|
|
||||||
|
|
|
@ -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 mainSidebar = new MainSidebar();
|
||||||
const settingsSidebar = new SettingsSidebar();
|
const settingsSidebar = new SettingsSidebar();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class SettingsUsersPage extends BasePage {
|
export class SettingsUsersPage extends BasePage {
|
||||||
url = '/settings/users';
|
url = '/settings/users';
|
||||||
|
|
||||||
|
|
|
@ -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 { BasePage } from '../base';
|
||||||
import { WorkflowsPage } from '../workflows';
|
import { WorkflowsPage } from '../workflows';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class MainSidebar extends BasePage {
|
export class MainSidebar extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
|
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class SettingsSidebar extends BasePage {
|
export class SettingsSidebar extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
|
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
|
||||||
|
|
|
@ -2,6 +2,14 @@ import { BasePage } from './base';
|
||||||
import { WorkflowsPage } from './workflows';
|
import { WorkflowsPage } from './workflows';
|
||||||
import { N8N_AUTH_COOKIE } from '../constants';
|
import { N8N_AUTH_COOKIE } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class SigninPage extends BasePage {
|
export class SigninPage extends BasePage {
|
||||||
url = '/signin';
|
url = '/signin';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class TemplatesPage extends BasePage {
|
export class TemplatesPage extends BasePage {
|
||||||
url = '/templates';
|
url = '/templates';
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,14 @@ import { BasePage } from './base';
|
||||||
|
|
||||||
import Chainable = Cypress.Chainable;
|
import Chainable = Cypress.Chainable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class VariablesPage extends BasePage {
|
export class VariablesPage extends BasePage {
|
||||||
url = '/variables';
|
url = '/variables';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class WorkerViewPage extends BasePage {
|
export class WorkerViewPage extends BasePage {
|
||||||
url = '/settings/workers';
|
url = '/settings/workers';
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,14 @@ import { WorkflowPage } from './workflow';
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class WorkflowExecutionsTab extends BasePage {
|
export class WorkflowExecutionsTab extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
executionsTabButton: () => cy.getByTestId('radio-button-executions'),
|
executionsTabButton: () => cy.getByTestId('radio-button-executions'),
|
||||||
|
|
|
@ -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';
|
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
|
||||||
|
|
||||||
const nodeCreator = new NodeCreator();
|
const nodeCreator = new NodeCreator();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class WorkflowPage extends BasePage {
|
export class WorkflowPage extends BasePage {
|
||||||
url = '/workflow/new';
|
url = '/workflow/new';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use functional composables from @composables instead.
|
||||||
|
* If a composable doesn't exist for your use case, please create a new one in:
|
||||||
|
* cypress/composables
|
||||||
|
*
|
||||||
|
* This class-based approach is being phased out in favor of more modular functional composables.
|
||||||
|
* Each getter and action in this class should be moved to individual composable functions.
|
||||||
|
*/
|
||||||
export class WorkflowsPage extends BasePage {
|
export class WorkflowsPage extends BasePage {
|
||||||
url = '/home/workflows';
|
url = '/home/workflows';
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,11 @@
|
||||||
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
||||||
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
||||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||||
|
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
|
||||||
|
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
|
||||||
|
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
|
||||||
|
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
|
||||||
|
"dev:e2e:server": "run-p start dev:fe:editor",
|
||||||
"clean": "turbo run clean --parallel",
|
"clean": "turbo run clean --parallel",
|
||||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||||
"format": "turbo run format && node scripts/format.mjs",
|
"format": "turbo run format && node scripts/format.mjs",
|
||||||
|
@ -55,6 +60,7 @@
|
||||||
"lefthook": "^1.7.15",
|
"lefthook": "^1.7.15",
|
||||||
"nock": "^13.3.2",
|
"nock": "^13.3.2",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
|
"npm-run-all2": "^7.0.2",
|
||||||
"p-limit": "^3.1.0",
|
"p-limit": "^3.1.0",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"run-script-os": "^1.0.7",
|
"run-script-os": "^1.0.7",
|
||||||
|
|
|
@ -39,13 +39,23 @@ export class TaskRunnersConfig {
|
||||||
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
|
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
|
||||||
maxOldSpaceSize: string = '';
|
maxOldSpaceSize: string = '';
|
||||||
|
|
||||||
/** How many concurrent tasks can a runner execute at a time */
|
/**
|
||||||
|
* How many concurrent tasks can a runner execute at a time
|
||||||
|
*
|
||||||
|
* Kept high for backwards compatibility - n8n v2 will reduce this to `5`
|
||||||
|
*/
|
||||||
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
||||||
maxConcurrency: number = 5;
|
maxConcurrency: number = 10;
|
||||||
|
|
||||||
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
/**
|
||||||
|
* How long (in seconds) a task is allowed to take for completion, else the
|
||||||
|
* task will be aborted. (In internal mode, the runner will also be
|
||||||
|
* restarted.) Must be greater than 0.
|
||||||
|
*
|
||||||
|
* Kept high for backwards compatibility - n8n v2 will reduce this to `60`
|
||||||
|
*/
|
||||||
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
||||||
taskTimeout: number = 60;
|
taskTimeout: number = 300; // 5 minutes
|
||||||
|
|
||||||
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
|
||||||
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
|
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
|
||||||
|
|
|
@ -229,8 +229,8 @@ describe('GlobalConfig', () => {
|
||||||
maxPayload: 1024 * 1024 * 1024,
|
maxPayload: 1024 * 1024 * 1024,
|
||||||
port: 5679,
|
port: 5679,
|
||||||
maxOldSpaceSize: '',
|
maxOldSpaceSize: '',
|
||||||
maxConcurrency: 5,
|
maxConcurrency: 10,
|
||||||
taskTimeout: 60,
|
taskTimeout: 300,
|
||||||
heartbeatInterval: 30,
|
heartbeatInterval: 30,
|
||||||
},
|
},
|
||||||
sentry: {
|
sentry: {
|
||||||
|
|
|
@ -131,6 +131,7 @@ export class LmChatGoogleVertex implements INodeType {
|
||||||
const credentials = await this.getCredentials('googleApi');
|
const credentials = await this.getCredentials('googleApi');
|
||||||
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
||||||
const email = (credentials.email as string).trim();
|
const email = (credentials.email as string).trim();
|
||||||
|
const region = credentials.region as string;
|
||||||
|
|
||||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||||
|
|
||||||
|
@ -165,6 +166,7 @@ export class LmChatGoogleVertex implements INodeType {
|
||||||
private_key: privateKey,
|
private_key: privateKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
location: region,
|
||||||
model: modelName,
|
model: modelName,
|
||||||
topK: options.topK,
|
topK: options.topK,
|
||||||
topP: options.topP,
|
topP: options.topP,
|
||||||
|
|
|
@ -89,14 +89,14 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
|
||||||
"value": "insert",
|
"value": "insert",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"action": "Retrieve documents for AI processing as Vector Store",
|
"action": "Retrieve documents for Chain/Tool as Vector Store",
|
||||||
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
|
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
|
||||||
"name": "Retrieve Documents (As Vector Store for AI Agent)",
|
"name": "Retrieve Documents (As Vector Store for Chain/Tool)",
|
||||||
"outputConnectionType": "ai_vectorStore",
|
"outputConnectionType": "ai_vectorStore",
|
||||||
"value": "retrieve",
|
"value": "retrieve",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"action": "Retrieve documents for AI processing as Tool",
|
"action": "Retrieve documents for AI Agent as Tool",
|
||||||
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
|
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
|
||||||
"name": "Retrieve Documents (As Tool for AI Agent)",
|
"name": "Retrieve Documents (As Tool for AI Agent)",
|
||||||
"outputConnectionType": "ai_tool",
|
"outputConnectionType": "ai_tool",
|
||||||
|
|
|
@ -111,17 +111,17 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
|
||||||
action: 'Add documents to vector store',
|
action: 'Add documents to vector store',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Retrieve Documents (As Vector Store for AI Agent)',
|
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
|
||||||
value: 'retrieve',
|
value: 'retrieve',
|
||||||
description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
|
description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
|
||||||
action: 'Retrieve documents for AI processing as Vector Store',
|
action: 'Retrieve documents for Chain/Tool as Vector Store',
|
||||||
outputConnectionType: NodeConnectionType.AiVectorStore,
|
outputConnectionType: NodeConnectionType.AiVectorStore,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Retrieve Documents (As Tool for AI Agent)',
|
name: 'Retrieve Documents (As Tool for AI Agent)',
|
||||||
value: 'retrieve-as-tool',
|
value: 'retrieve-as-tool',
|
||||||
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
|
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
|
||||||
action: 'Retrieve documents for AI processing as Tool',
|
action: 'Retrieve documents for AI Agent as Tool',
|
||||||
outputConnectionType: NodeConnectionType.AiTool,
|
outputConnectionType: NodeConnectionType.AiTool,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -23,8 +23,13 @@ export class BaseRunnerConfig {
|
||||||
@Env('N8N_RUNNERS_MAX_PAYLOAD')
|
@Env('N8N_RUNNERS_MAX_PAYLOAD')
|
||||||
maxPayloadSize: number = 1024 * 1024 * 1024;
|
maxPayloadSize: number = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many concurrent tasks can a runner execute at a time
|
||||||
|
*
|
||||||
|
* Kept high for backwards compatibility - n8n v2 will reduce this to `5`
|
||||||
|
*/
|
||||||
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
||||||
maxConcurrency: number = 5;
|
maxConcurrency: number = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long (in seconds) a runner may be idle for before exit. Intended
|
* How long (in seconds) a runner may be idle for before exit. Intended
|
||||||
|
@ -37,8 +42,15 @@ export class BaseRunnerConfig {
|
||||||
@Env('GENERIC_TIMEZONE')
|
@Env('GENERIC_TIMEZONE')
|
||||||
timezone: string = 'America/New_York';
|
timezone: string = 'America/New_York';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long (in seconds) a task is allowed to take for completion, else the
|
||||||
|
* task will be aborted. (In internal mode, the runner will also be
|
||||||
|
* restarted.) Must be greater than 0.
|
||||||
|
*
|
||||||
|
* Kept high for backwards compatibility - n8n v2 will reduce this to `60`
|
||||||
|
*/
|
||||||
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
@Env('N8N_RUNNERS_TASK_TIMEOUT')
|
||||||
taskTimeout: number = 60;
|
taskTimeout: number = 300; // 5 minutes
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
healthcheckServer!: HealthcheckServerConfig;
|
healthcheckServer!: HealthcheckServerConfig;
|
||||||
|
|
|
@ -195,4 +195,3 @@ export const WsStatusCodes = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
|
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
|
||||||
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
|
|
||||||
|
|
|
@ -6,10 +6,11 @@ import {
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { WritableStream } from 'node:stream/web';
|
import { WritableStream } from 'node:stream/web';
|
||||||
|
|
||||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
|
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||||
import { CredentialsService } from '@/credentials/credentials.service';
|
import { CredentialsService } from '@/credentials/credentials.service';
|
||||||
import { Body, Post, RestController } from '@/decorators';
|
import { Body, Post, RestController } from '@/decorators';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
|
|
|
@ -35,4 +35,23 @@ export class TestRun extends WithTimestampsAndStringId {
|
||||||
|
|
||||||
@Column(jsonColumnType, { nullable: true })
|
@Column(jsonColumnType, { nullable: true })
|
||||||
metrics: AggregatedTestRunMetrics;
|
metrics: AggregatedTestRunMetrics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of the test cases, matching the filter condition of the test definition (specified annotationTag)
|
||||||
|
*/
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
totalCases: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of test cases that passed (evaluation workflow was executed successfully)
|
||||||
|
*/
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
passedCases: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of failed test cases
|
||||||
|
* (any unexpected exception happened during the execution or evaluation workflow ended with an error)
|
||||||
|
*/
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
failedCases: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||||
|
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -154,4 +155,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
AddProjectIcons1729607673469,
|
AddProjectIcons1729607673469,
|
||||||
|
AddStatsColumnsToTestRun1736172058779,
|
||||||
];
|
];
|
||||||
|
|
|
@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||||
|
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -154,4 +155,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
AddProjectIcons1729607673469,
|
AddProjectIcons1729607673469,
|
||||||
|
AddStatsColumnsToTestRun1736172058779,
|
||||||
];
|
];
|
||||||
|
|
|
@ -73,6 +73,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||||
|
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -148,6 +149,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
AddProjectIcons1729607673469,
|
AddProjectIcons1729607673469,
|
||||||
|
AddStatsColumnsToTestRun1736172058779,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -21,14 +21,28 @@ export class TestRunRepository extends Repository<TestRun> {
|
||||||
return await this.save(testRun);
|
return await this.save(testRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRunning(id: string) {
|
async markAsRunning(id: string, totalCases: number) {
|
||||||
return await this.update(id, { status: 'running', runAt: new Date() });
|
return await this.update(id, {
|
||||||
|
status: 'running',
|
||||||
|
runAt: new Date(),
|
||||||
|
totalCases,
|
||||||
|
passedCases: 0,
|
||||||
|
failedCases: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
|
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
|
||||||
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
|
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async incrementPassed(id: string) {
|
||||||
|
return await this.increment({ id }, 'passedCases', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementFailed(id: string) {
|
||||||
|
return await this.increment({ id }, 'failedCases', 1);
|
||||||
|
}
|
||||||
|
|
||||||
async getMany(testDefinitionId: string, options: ListQuery.Options) {
|
async getMany(testDefinitionId: string, options: ListQuery.Options) {
|
||||||
const findManyOptions: FindManyOptions<TestRun> = {
|
const findManyOptions: FindManyOptions<TestRun> = {
|
||||||
where: { testDefinition: { id: testDefinitionId } },
|
where: { testDefinition: { id: testDefinitionId } },
|
||||||
|
|
|
@ -2,7 +2,8 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
import { stringify } from 'flatted';
|
import { stringify } from 'flatted';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { mock, mockDeep } from 'jest-mock-extended';
|
import { mock, mockDeep } from 'jest-mock-extended';
|
||||||
import type { GenericValue, IRun } from 'n8n-workflow';
|
import type { ErrorReporter } from 'n8n-core';
|
||||||
|
import type { ExecutionError, GenericValue, IRun } from 'n8n-workflow';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import type { ActiveExecutions } from '@/active-executions';
|
import type { ActiveExecutions } from '@/active-executions';
|
||||||
|
@ -90,6 +91,16 @@ function mockExecutionData() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockErrorExecutionData() {
|
||||||
|
return mock<IRun>({
|
||||||
|
data: {
|
||||||
|
resultData: {
|
||||||
|
error: mock<ExecutionError>(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
||||||
return mock<IRun>({
|
return mock<IRun>({
|
||||||
data: {
|
data: {
|
||||||
|
@ -110,6 +121,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// error is an optional prop, but jest-mock-extended will mock it by default,
|
||||||
|
// which affects the code logic. So, we need to explicitly set it to undefined.
|
||||||
|
error: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -156,6 +170,8 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository.createTestRun.mockClear();
|
testRunRepository.createTestRun.mockClear();
|
||||||
testRunRepository.markAsRunning.mockClear();
|
testRunRepository.markAsRunning.mockClear();
|
||||||
testRunRepository.markAsCompleted.mockClear();
|
testRunRepository.markAsCompleted.mockClear();
|
||||||
|
testRunRepository.incrementFailed.mockClear();
|
||||||
|
testRunRepository.incrementPassed.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create an instance of TestRunnerService', async () => {
|
test('should create an instance of TestRunnerService', async () => {
|
||||||
|
@ -167,6 +183,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||||
|
@ -181,6 +198,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
@ -218,6 +236,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
@ -298,12 +317,185 @@ describe('TestRunnerService', () => {
|
||||||
// Check Test Run status was updated correctly
|
// Check Test Run status was updated correctly
|
||||||
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
||||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
||||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
|
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
|
||||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||||
metric1: 0.75,
|
metric1: 0.75,
|
||||||
metric2: 0,
|
metric2: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
|
||||||
|
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should properly count passed and failed executions', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
testRunRepository,
|
||||||
|
testMetricRepository,
|
||||||
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||||
|
id: 'evaluation-workflow-id',
|
||||||
|
...wfEvaluationJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||||
|
|
||||||
|
// Mock executions of workflow under test
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-3')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
// Mock executions of evaluation workflow
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-2')
|
||||||
|
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-4')
|
||||||
|
.mockRejectedValue(new Error('Some error'));
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
|
mockedNodes: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should properly count failed test executions', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
testRunRepository,
|
||||||
|
testMetricRepository,
|
||||||
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||||
|
id: 'evaluation-workflow-id',
|
||||||
|
...wfEvaluationJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||||
|
|
||||||
|
// Mock executions of workflow under test
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-3')
|
||||||
|
.mockResolvedValue(mockErrorExecutionData());
|
||||||
|
|
||||||
|
// Mock executions of evaluation workflow
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-2')
|
||||||
|
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
|
mockedNodes: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should properly count failed evaluations', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
testRunRepository,
|
||||||
|
testMetricRepository,
|
||||||
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||||
|
id: 'evaluation-workflow-id',
|
||||||
|
...wfEvaluationJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||||
|
|
||||||
|
// Mock executions of workflow under test
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-3')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
// Mock executions of evaluation workflow
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-2')
|
||||||
|
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-4')
|
||||||
|
.mockResolvedValue(mockErrorExecutionData());
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
|
mockedNodes: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should specify correct start nodes when running workflow under test', async () => {
|
test('should specify correct start nodes when running workflow under test', async () => {
|
||||||
|
@ -315,6 +507,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
@ -388,6 +581,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||||
|
@ -412,6 +606,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { parse } from 'flatted';
|
import { parse } from 'flatted';
|
||||||
|
import { ErrorReporter } from 'n8n-core';
|
||||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
|
@ -45,6 +46,7 @@ export class TestRunnerService {
|
||||||
private readonly testRunRepository: TestRunRepository,
|
private readonly testRunRepository: TestRunRepository,
|
||||||
private readonly testMetricRepository: TestMetricRepository,
|
private readonly testMetricRepository: TestMetricRepository,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
|
private readonly errorReporter: ErrorReporter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -134,6 +136,7 @@ export class TestRunnerService {
|
||||||
evaluationWorkflow: WorkflowEntity,
|
evaluationWorkflow: WorkflowEntity,
|
||||||
expectedData: IRunData,
|
expectedData: IRunData,
|
||||||
actualData: IRunData,
|
actualData: IRunData,
|
||||||
|
testRunId?: string,
|
||||||
) {
|
) {
|
||||||
// Prepare the evaluation wf input data.
|
// Prepare the evaluation wf input data.
|
||||||
// Provide both the expected data and the actual data
|
// Provide both the expected data and the actual data
|
||||||
|
@ -146,7 +149,13 @@ export class TestRunnerService {
|
||||||
|
|
||||||
// Prepare the data to run the evaluation workflow
|
// Prepare the data to run the evaluation workflow
|
||||||
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
|
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
|
||||||
|
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
|
||||||
|
// So that we can fetch all execution runs for a test run
|
||||||
|
if (testRunId && data.executionData) {
|
||||||
|
data.executionData.resultData.metadata = {
|
||||||
|
testRunId,
|
||||||
|
};
|
||||||
|
}
|
||||||
data.executionMode = 'evaluation';
|
data.executionMode = 'evaluation';
|
||||||
|
|
||||||
// Trigger the evaluation workflow
|
// Trigger the evaluation workflow
|
||||||
|
@ -223,52 +232,66 @@ export class TestRunnerService {
|
||||||
|
|
||||||
// 2. Run over all the test cases
|
// 2. Run over all the test cases
|
||||||
|
|
||||||
await this.testRunRepository.markAsRunning(testRun.id);
|
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
|
||||||
|
|
||||||
// Object to collect the results of the evaluation workflow executions
|
// Object to collect the results of the evaluation workflow executions
|
||||||
const metrics = new EvaluationMetrics(testMetricNames);
|
const metrics = new EvaluationMetrics(testMetricNames);
|
||||||
|
|
||||||
for (const { id: pastExecutionId } of pastExecutions) {
|
for (const { id: pastExecutionId } of pastExecutions) {
|
||||||
// Fetch past execution with data
|
try {
|
||||||
const pastExecution = await this.executionRepository.findOne({
|
// Fetch past execution with data
|
||||||
where: { id: pastExecutionId },
|
const pastExecution = await this.executionRepository.findOne({
|
||||||
relations: ['executionData', 'metadata'],
|
where: { id: pastExecutionId },
|
||||||
});
|
relations: ['executionData', 'metadata'],
|
||||||
assert(pastExecution, 'Execution not found');
|
});
|
||||||
|
assert(pastExecution, 'Execution not found');
|
||||||
|
|
||||||
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
|
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
|
||||||
|
|
||||||
// Run the test case and wait for it to finish
|
// Run the test case and wait for it to finish
|
||||||
const testCaseExecution = await this.runTestCase(
|
const testCaseExecution = await this.runTestCase(
|
||||||
workflow,
|
workflow,
|
||||||
executionData,
|
executionData,
|
||||||
pastExecution.executionData.workflowData,
|
pastExecution.executionData.workflowData,
|
||||||
test.mockedNodes,
|
test.mockedNodes,
|
||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// In case of a permission check issue, the test case execution will be undefined.
|
// In case of a permission check issue, the test case execution will be undefined.
|
||||||
// Skip them and continue with the next test case
|
// Skip them, increment the failed count and continue with the next test case
|
||||||
if (!testCaseExecution) {
|
if (!testCaseExecution) {
|
||||||
continue;
|
await this.testRunRepository.incrementFailed(testRun.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the results of the test case execution
|
||||||
|
const testCaseRunData = testCaseExecution.data.resultData.runData;
|
||||||
|
|
||||||
|
// Get the original runData from the test case execution data
|
||||||
|
const originalRunData = executionData.resultData.runData;
|
||||||
|
|
||||||
|
// Run the evaluation workflow with the original and new run data
|
||||||
|
const evalExecution = await this.runTestCaseEvaluation(
|
||||||
|
evaluationWorkflow,
|
||||||
|
originalRunData,
|
||||||
|
testCaseRunData,
|
||||||
|
testRun.id,
|
||||||
|
);
|
||||||
|
assert(evalExecution);
|
||||||
|
|
||||||
|
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
||||||
|
|
||||||
|
if (evalExecution.data.resultData.error) {
|
||||||
|
await this.testRunRepository.incrementFailed(testRun.id);
|
||||||
|
} else {
|
||||||
|
await this.testRunRepository.incrementPassed(testRun.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// In case of an unexpected error, increment the failed count and continue with the next test case
|
||||||
|
await this.testRunRepository.incrementFailed(testRun.id);
|
||||||
|
|
||||||
|
this.errorReporter.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect the results of the test case execution
|
|
||||||
const testCaseRunData = testCaseExecution.data.resultData.runData;
|
|
||||||
|
|
||||||
// Get the original runData from the test case execution data
|
|
||||||
const originalRunData = executionData.resultData.runData;
|
|
||||||
|
|
||||||
// Run the evaluation workflow with the original and new run data
|
|
||||||
const evalExecution = await this.runTestCaseEvaluation(
|
|
||||||
evaluationWorkflow,
|
|
||||||
originalRunData,
|
|
||||||
testCaseRunData,
|
|
||||||
);
|
|
||||||
assert(evalExecution);
|
|
||||||
|
|
||||||
// Extract the output of the last node executed in the evaluation workflow
|
|
||||||
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const aggregatedMetrics = metrics.getAggregatedMetrics();
|
const aggregatedMetrics = metrics.getAggregatedMetrics();
|
||||||
|
|
|
@ -5,7 +5,9 @@ import type { INode, INodesGraphResult } from 'n8n-workflow';
|
||||||
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
|
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import { N8N_VERSION } from '@/constants';
|
import { N8N_VERSION } from '@/constants';
|
||||||
|
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
import type { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
||||||
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
const nodeTypes = mock<NodeTypes>();
|
const nodeTypes = mock<NodeTypes>();
|
||||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||||
|
const credentialsRepository = mock<CredentialsRepository>();
|
||||||
const eventService = new EventService();
|
const eventService = new EventService();
|
||||||
|
|
||||||
let telemetryEventRelay: TelemetryEventRelay;
|
let telemetryEventRelay: TelemetryEventRelay;
|
||||||
|
@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
projectRelationRepository,
|
projectRelationRepository,
|
||||||
|
credentialsRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
await telemetryEventRelay.init();
|
await telemetryEventRelay.init();
|
||||||
|
@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
projectRelationRepository,
|
projectRelationRepository,
|
||||||
|
credentialsRepository,
|
||||||
);
|
);
|
||||||
// @ts-expect-error Private method
|
// @ts-expect-error Private method
|
||||||
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
||||||
|
@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
projectRelationRepository,
|
projectRelationRepository,
|
||||||
|
credentialsRepository,
|
||||||
);
|
);
|
||||||
// @ts-expect-error Private method
|
// @ts-expect-error Private method
|
||||||
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
||||||
|
@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => {
|
||||||
|
|
||||||
it('should call telemetry.track when manual node execution finished', async () => {
|
it('should call telemetry.track when manual node execution finished', async () => {
|
||||||
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||||
|
credentialsRepository.findOneBy.mockResolvedValue(
|
||||||
|
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
|
||||||
|
);
|
||||||
|
|
||||||
const runData = {
|
const runData = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => {
|
||||||
error_node_id: '1',
|
error_node_id: '1',
|
||||||
node_id: '1',
|
node_id: '1',
|
||||||
node_type: 'n8n-nodes-base.jira',
|
node_type: 'n8n-nodes-base.jira',
|
||||||
|
is_managed: false,
|
||||||
|
credential_type: null,
|
||||||
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -1498,5 +1509,187 @@ describe('TelemetryEventRelay', () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call telemetry.track when manual node execution finished with is_managed and credential_type properties', async () => {
|
||||||
|
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||||
|
credentialsRepository.findOneBy.mockResolvedValue(
|
||||||
|
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runData = {
|
||||||
|
status: 'error',
|
||||||
|
mode: 'manual',
|
||||||
|
data: {
|
||||||
|
executionData: {
|
||||||
|
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||||||
|
},
|
||||||
|
startData: {
|
||||||
|
destinationNode: 'OpenAI',
|
||||||
|
runNodeFilter: ['OpenAI'],
|
||||||
|
},
|
||||||
|
resultData: {
|
||||||
|
runData: {},
|
||||||
|
lastNodeExecuted: 'OpenAI',
|
||||||
|
error: new NodeApiError(
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
name: 'Jira',
|
||||||
|
type: 'n8n-nodes-base.jira',
|
||||||
|
parameters: {},
|
||||||
|
position: [100, 200],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Error message',
|
||||||
|
description: 'Incorrect API key provided',
|
||||||
|
httpCode: '401',
|
||||||
|
stack: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Error message',
|
||||||
|
description: 'Error description',
|
||||||
|
level: 'warning',
|
||||||
|
functionality: 'regular',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as IRun;
|
||||||
|
|
||||||
|
const nodeGraph: INodesGraphResult = {
|
||||||
|
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
|
||||||
|
nameIndices: {
|
||||||
|
Jira: '1',
|
||||||
|
OpenAI: '1',
|
||||||
|
},
|
||||||
|
} as unknown as INodesGraphResult;
|
||||||
|
|
||||||
|
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
|
||||||
|
.mockImplementation(
|
||||||
|
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const event: RelayEventMap['workflow-post-execute'] = {
|
||||||
|
workflow: mockWorkflowBase,
|
||||||
|
executionId: 'execution123',
|
||||||
|
userId: 'user123',
|
||||||
|
runData,
|
||||||
|
};
|
||||||
|
|
||||||
|
eventService.emit('workflow-post-execute', event);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
|
||||||
|
id: 'nhu-l8E4hX',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(telemetry.track).toHaveBeenCalledWith(
|
||||||
|
'Manual node exec finished',
|
||||||
|
expect.objectContaining({
|
||||||
|
webhook_domain: null,
|
||||||
|
user_id: 'user123',
|
||||||
|
workflow_id: 'workflow123',
|
||||||
|
status: 'error',
|
||||||
|
executionStatus: 'error',
|
||||||
|
sharing_role: 'sharee',
|
||||||
|
error_message: 'Error message',
|
||||||
|
error_node_type: 'n8n-nodes-base.jira',
|
||||||
|
error_node_id: '1',
|
||||||
|
node_id: '1',
|
||||||
|
node_type: 'n8n-nodes-base.jira',
|
||||||
|
|
||||||
|
is_managed: true,
|
||||||
|
credential_type: 'openAiApi',
|
||||||
|
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workflow_id: 'workflow123',
|
||||||
|
success: false,
|
||||||
|
is_manual: true,
|
||||||
|
execution_mode: 'manual',
|
||||||
|
version_cli: N8N_VERSION,
|
||||||
|
error_message: 'Error message',
|
||||||
|
error_node_type: 'n8n-nodes-base.jira',
|
||||||
|
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||||
|
error_node_id: '1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call telemetry.track when user ran out of free AI credits', async () => {
|
||||||
|
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||||
|
credentialsRepository.findOneBy.mockResolvedValue(
|
||||||
|
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runData = {
|
||||||
|
status: 'error',
|
||||||
|
mode: 'trigger',
|
||||||
|
data: {
|
||||||
|
startData: {
|
||||||
|
destinationNode: 'OpenAI',
|
||||||
|
runNodeFilter: ['OpenAI'],
|
||||||
|
},
|
||||||
|
executionData: {
|
||||||
|
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||||||
|
},
|
||||||
|
resultData: {
|
||||||
|
runData: {},
|
||||||
|
lastNodeExecuted: 'OpenAI',
|
||||||
|
error: new NodeApiError(
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
name: 'OpenAI',
|
||||||
|
type: 'n8n-nodes-base.openAi',
|
||||||
|
parameters: {},
|
||||||
|
position: [100, 200],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: `400 - ${JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'error message',
|
||||||
|
type: 'error_type',
|
||||||
|
code: 200,
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
error: {
|
||||||
|
message: 'error message',
|
||||||
|
type: 'error_type',
|
||||||
|
code: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
httpCode: '400',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as IRun;
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(TelemetryHelpers, 'userInInstanceRanOutOfFreeAiCredits')
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
|
||||||
|
const event: RelayEventMap['workflow-post-execute'] = {
|
||||||
|
workflow: mockWorkflowBase,
|
||||||
|
executionId: 'execution123',
|
||||||
|
userId: 'user123',
|
||||||
|
runData,
|
||||||
|
};
|
||||||
|
|
||||||
|
eventService.emit('workflow-post-execute', event);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(telemetry.track).toHaveBeenCalledWith('User ran out of free AI credits');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { get as pslGet } from 'psl';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { N8N_VERSION } from '@/constants';
|
import { N8N_VERSION } from '@/constants';
|
||||||
|
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
||||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||||
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
) {
|
) {
|
||||||
super(eventService);
|
super(eventService);
|
||||||
}
|
}
|
||||||
|
@ -632,6 +634,10 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
let nodeGraphResult: INodesGraphResult | null = null;
|
let nodeGraphResult: INodesGraphResult | null = null;
|
||||||
|
|
||||||
if (!telemetryProperties.success && runData?.data.resultData.error) {
|
if (!telemetryProperties.success && runData?.data.resultData.error) {
|
||||||
|
if (TelemetryHelpers.userInInstanceRanOutOfFreeAiCredits(runData)) {
|
||||||
|
this.telemetry.track('User ran out of free AI credits');
|
||||||
|
}
|
||||||
|
|
||||||
telemetryProperties.error_message = runData?.data.resultData.error.message;
|
telemetryProperties.error_message = runData?.data.resultData.error.message;
|
||||||
let errorNodeName =
|
let errorNodeName =
|
||||||
'node' in runData?.data.resultData.error
|
'node' in runData?.data.resultData.error
|
||||||
|
@ -693,6 +699,8 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
error_node_id: telemetryProperties.error_node_id as string,
|
error_node_id: telemetryProperties.error_node_id as string,
|
||||||
webhook_domain: null,
|
webhook_domain: null,
|
||||||
sharing_role: userRole,
|
sharing_role: userRole,
|
||||||
|
credential_type: null,
|
||||||
|
is_managed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!manualExecEventProperties.node_graph_string) {
|
if (!manualExecEventProperties.node_graph_string) {
|
||||||
|
@ -703,7 +711,18 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runData.data.startData?.destinationNode) {
|
if (runData.data.startData?.destinationNode) {
|
||||||
const telemetryPayload = {
|
const credentialsData = TelemetryHelpers.extractLastExecutedNodeCredentialData(runData);
|
||||||
|
if (credentialsData) {
|
||||||
|
manualExecEventProperties.credential_type = credentialsData.credentialType;
|
||||||
|
const credential = await this.credentialsRepository.findOneBy({
|
||||||
|
id: credentialsData.credentialId,
|
||||||
|
});
|
||||||
|
if (credential) {
|
||||||
|
manualExecEventProperties.is_managed = credential.isManaged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const telemetryPayload: ITelemetryTrackProperties = {
|
||||||
...manualExecEventProperties,
|
...manualExecEventProperties,
|
||||||
node_type: TelemetryHelpers.getNodeTypeForName(
|
node_type: TelemetryHelpers.getNodeTypeForName(
|
||||||
workflow,
|
workflow,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { GlobalConfig } from '@n8n/config';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { rateLimit as expressRateLimit } from 'express-rate-limit';
|
||||||
import { Logger } from 'n8n-core';
|
import { Logger } from 'n8n-core';
|
||||||
import * as a from 'node:assert/strict';
|
import * as a from 'node:assert/strict';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
@ -147,8 +148,16 @@ export class TaskRunnerServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private configureRoutes() {
|
private configureRoutes() {
|
||||||
|
const createRateLimiter = () =>
|
||||||
|
expressRateLimit({
|
||||||
|
windowMs: 1000,
|
||||||
|
limit: 5,
|
||||||
|
message: { message: 'Too many requests' },
|
||||||
|
});
|
||||||
|
|
||||||
this.app.use(
|
this.app.use(
|
||||||
this.upgradeEndpoint,
|
this.upgradeEndpoint,
|
||||||
|
createRateLimiter(),
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
this.taskRunnerAuthController.authMiddleware,
|
this.taskRunnerAuthController.authMiddleware,
|
||||||
(req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) =>
|
(req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) =>
|
||||||
|
@ -158,6 +167,7 @@ export class TaskRunnerServer {
|
||||||
const authEndpoint = `${this.getEndpointBasePath()}/auth`;
|
const authEndpoint = `${this.getEndpointBasePath()}/auth`;
|
||||||
this.app.post(
|
this.app.post(
|
||||||
authEndpoint,
|
authEndpoint,
|
||||||
|
createRateLimiter(),
|
||||||
send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)),
|
send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -147,6 +147,12 @@ export class WorkflowExecutionService {
|
||||||
triggerToStartFrom,
|
triggerToStartFrom,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
||||||
|
|
||||||
|
if (pinnedTrigger && !hasRunData(pinnedTrigger)) {
|
||||||
|
data.startNodes = [{ name: pinnedTrigger.name, sourceData: null }];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Historically, manual executions in scaling mode ran in the main process,
|
* Historically, manual executions in scaling mode ran in the main process,
|
||||||
* so some execution details were never persisted in the database.
|
* so some execution details were never persisted in the database.
|
||||||
|
@ -160,7 +166,7 @@ export class WorkflowExecutionService {
|
||||||
) {
|
) {
|
||||||
data.executionData = {
|
data.executionData = {
|
||||||
startData: {
|
startData: {
|
||||||
startNodes,
|
startNodes: data.startNodes,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
},
|
},
|
||||||
resultData: {
|
resultData: {
|
||||||
|
@ -176,12 +182,6 @@ export class WorkflowExecutionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
|
||||||
|
|
||||||
if (pinnedTrigger && !hasRunData(pinnedTrigger)) {
|
|
||||||
data.startNodes = [{ name: pinnedTrigger.name, sourceData: null }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const executionId = await this.workflowRunner.run(data);
|
const executionId = await this.workflowRunner.run(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||||
|
|
||||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
|
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||||
import type { Project } from '@/databases/entities/project';
|
import type { Project } from '@/databases/entities/project';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
|
|
|
@ -93,7 +93,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
|
||||||
const testRunRepository = Container.get(TestRunRepository);
|
const testRunRepository = Container.get(TestRunRepository);
|
||||||
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
|
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
|
||||||
// Mark as running just to make a slight delay between the runs
|
// Mark as running just to make a slight delay between the runs
|
||||||
await testRunRepository.markAsRunning(testRun1.id);
|
await testRunRepository.markAsRunning(testRun1.id, 10);
|
||||||
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
|
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
|
||||||
|
|
||||||
// Fetch the first page
|
// Fetch the first page
|
||||||
|
|
|
@ -19,4 +19,26 @@ describe('TaskRunnerServer', () => {
|
||||||
await agent.get('/healthz').expect(200);
|
await agent.get('/healthz').expect(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/runners/_ws', () => {
|
||||||
|
it('should return 429 when too many requests are made', async () => {
|
||||||
|
await agent.post('/runners/_ws').send({}).expect(401);
|
||||||
|
await agent.post('/runners/_ws').send({}).expect(401);
|
||||||
|
await agent.post('/runners/_ws').send({}).expect(401);
|
||||||
|
await agent.post('/runners/_ws').send({}).expect(401);
|
||||||
|
await agent.post('/runners/_ws').send({}).expect(401);
|
||||||
|
await agent.post('/runners/_ws').send({}).expect(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/runners/auth', () => {
|
||||||
|
it('should return 429 when too many requests are made', async () => {
|
||||||
|
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||||
|
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||||
|
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||||
|
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||||
|
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
|
||||||
|
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -61,11 +61,7 @@ const validateResourceMapperValue = (
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.valid) {
|
if (!validationResult.valid) {
|
||||||
if (!resourceMapperField.ignoreTypeMismatchErrors) {
|
return { ...validationResult, fieldName: key };
|
||||||
return { ...validationResult, fieldName: key };
|
|
||||||
} else {
|
|
||||||
paramValues[key] = resolvedValue;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// If it's valid, set the casted value
|
// If it's valid, set the casted value
|
||||||
paramValues[key] = validationResult.newValue;
|
paramValues[key] = validationResult.newValue;
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:dev": "vitest",
|
"test:dev": "vitest",
|
||||||
"build:storybook": "storybook build",
|
"build:storybook": "storybook build",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006 --no-open",
|
||||||
"chromatic": "chromatic",
|
"chromatic": "chromatic",
|
||||||
"format": "biome format --write . && prettier --write . --ignore-path ../../.prettierignore",
|
"format": "biome format --write . && prettier --write . --ignore-path ../../.prettierignore",
|
||||||
"format:check": "biome ci . && prettier --check . --ignore-path ../../.prettierignore",
|
"format:check": "biome ci . && prettier --check . --ignore-path ../../.prettierignore",
|
||||||
|
|
|
@ -31,6 +31,10 @@ const props = defineProps({
|
||||||
multiple: {
|
multiple: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
multipleLimit: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
filterMethod: {
|
filterMethod: {
|
||||||
type: Function,
|
type: Function,
|
||||||
},
|
},
|
||||||
|
@ -120,6 +124,7 @@ defineExpose({
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-bind="{ ...$props, ...listeners }"
|
v-bind="{ ...$props, ...listeners }"
|
||||||
ref="innerSelect"
|
ref="innerSelect"
|
||||||
|
:multiple-limit="props.multipleLimit"
|
||||||
:model-value="props.modelValue ?? undefined"
|
:model-value="props.modelValue ?? undefined"
|
||||||
:size="computedSize"
|
:size="computedSize"
|
||||||
:popper-class="props.popperClass"
|
:popper-class="props.popperClass"
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
|
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
|
||||||
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
|
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
|
||||||
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%);
|
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%);
|
||||||
|
--prim-gray-320-alpha-010: hsla(var(--prim-gray-h), 10%, 68%, 0.1);
|
||||||
--prim-gray-200: hsl(var(--prim-gray-h), 18%, 80%);
|
--prim-gray-200: hsl(var(--prim-gray-h), 18%, 80%);
|
||||||
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
|
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
|
||||||
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);
|
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);
|
||||||
|
|
|
@ -139,12 +139,20 @@
|
||||||
--color-infobox-examples-border-color: var(--prim-gray-670);
|
--color-infobox-examples-border-color: var(--prim-gray-670);
|
||||||
|
|
||||||
// Code
|
// Code
|
||||||
--color-code-tags-string: var(--prim-color-alt-f-tint-150);
|
--color-code-tags-string: #9ecbff;
|
||||||
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
|
--color-code-tags-regex: #9ecbff;
|
||||||
--color-code-tags-keyword: var(--prim-color-alt-g-tint-150);
|
--color-code-tags-primitive: #79b8ff;
|
||||||
--color-code-tags-operator: var(--prim-color-alt-h);
|
--color-code-tags-keyword: #f97583;
|
||||||
--color-code-tags-variable: var(--prim-color-primary-tint-100);
|
--color-code-tags-variable: #79b8ff;
|
||||||
--color-code-tags-definition: var(--prim-color-alt-e);
|
--color-code-tags-parameter: #e1e4e8;
|
||||||
|
--color-code-tags-function: #b392f0;
|
||||||
|
--color-code-tags-constant: #79b8ff;
|
||||||
|
--color-code-tags-property: #79b8ff;
|
||||||
|
--color-code-tags-type: #b392f0;
|
||||||
|
--color-code-tags-class: #b392f0;
|
||||||
|
--color-code-tags-heading: #79b8ff;
|
||||||
|
--color-code-tags-invalid: #f97583;
|
||||||
|
--color-code-tags-comment: #6a737d;
|
||||||
--color-json-default: var(--prim-color-secondary-tint-200);
|
--color-json-default: var(--prim-color-secondary-tint-200);
|
||||||
--color-json-null: var(--color-danger);
|
--color-json-null: var(--color-danger);
|
||||||
--color-json-boolean: var(--prim-color-alt-a);
|
--color-json-boolean: var(--prim-color-alt-a);
|
||||||
|
@ -155,15 +163,18 @@
|
||||||
--color-json-brackets-hover: var(--prim-color-alt-e);
|
--color-json-brackets-hover: var(--prim-color-alt-e);
|
||||||
--color-json-line: var(--prim-gray-200);
|
--color-json-line: var(--prim-gray-200);
|
||||||
--color-json-highlight: var(--color-background-base);
|
--color-json-highlight: var(--color-background-base);
|
||||||
--color-code-background: var(--prim-gray-800);
|
--color-code-background: var(--prim-gray-820);
|
||||||
--color-code-background-readonly: var(--prim-gray-740);
|
--color-code-background-readonly: var(--prim-gray-740);
|
||||||
--color-code-lineHighlight: var(--prim-gray-740);
|
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
|
||||||
--color-code-foreground: var(--prim-gray-70);
|
--color-code-foreground: var(--prim-gray-70);
|
||||||
--color-code-caret: var(--prim-gray-10);
|
--color-code-caret: var(--prim-gray-10);
|
||||||
--color-code-selection: var(--prim-color-alt-e-alpha-04);
|
--color-code-selection: #3392ff44;
|
||||||
--color-code-gutterBackground: var(--prim-gray-670);
|
--color-code-selection-highlight: #17e5e633;
|
||||||
--color-code-gutterForeground: var(--prim-gray-320);
|
--color-code-gutter-background: var(--prim-gray-820);
|
||||||
--color-code-tags-comment: var(--prim-gray-200);
|
--color-code-gutter-foreground: var(--prim-gray-320);
|
||||||
|
--color-code-gutter-foreground-active: var(--prim-gray-10);
|
||||||
|
--color-code-indentation-marker: var(--prim-gray-740);
|
||||||
|
--color-code-indentation-marker-active: var(--prim-gray-670);
|
||||||
--color-line-break: var(--prim-gray-420);
|
--color-line-break: var(--prim-gray-420);
|
||||||
--color-code-line-break: var(--prim-color-secondary-tint-100);
|
--color-code-line-break: var(--prim-color-secondary-tint-100);
|
||||||
|
|
||||||
|
|
|
@ -183,12 +183,20 @@
|
||||||
--color-infobox-examples-border-color: var(--color-foreground-light);
|
--color-infobox-examples-border-color: var(--color-foreground-light);
|
||||||
|
|
||||||
// Code
|
// Code
|
||||||
--color-code-tags-string: var(--prim-color-alt-f);
|
--color-code-tags-string: #032f62;
|
||||||
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
|
--color-code-tags-regex: #032f62;
|
||||||
--color-code-tags-keyword: var(--prim-color-alt-g);
|
--color-code-tags-primitive: #005cc5;
|
||||||
--color-code-tags-operator: var(--prim-color-alt-h);
|
--color-code-tags-keyword: #d73a49;
|
||||||
--color-code-tags-variable: var(--prim-color-alt-c-shade-100);
|
--color-code-tags-variable: #005cc5;
|
||||||
--color-code-tags-definition: var(--prim-color-alt-e-shade-150);
|
--color-code-tags-parameter: #24292e;
|
||||||
|
--color-code-tags-function: #6f42c1;
|
||||||
|
--color-code-tags-constant: #005cc5;
|
||||||
|
--color-code-tags-property: #005cc5;
|
||||||
|
--color-code-tags-type: #005cc5;
|
||||||
|
--color-code-tags-class: #6f42c1;
|
||||||
|
--color-code-tags-heading: #005cc5;
|
||||||
|
--color-code-tags-invalid: #cb2431;
|
||||||
|
--color-code-tags-comment: #6a737d;
|
||||||
--color-json-default: var(--prim-color-secondary-shade-100);
|
--color-json-default: var(--prim-color-secondary-shade-100);
|
||||||
--color-json-null: var(--prim-color-alt-c);
|
--color-json-null: var(--prim-color-alt-c);
|
||||||
--color-json-boolean: var(--prim-color-alt-a);
|
--color-json-boolean: var(--prim-color-alt-a);
|
||||||
|
@ -201,13 +209,16 @@
|
||||||
--color-json-highlight: var(--prim-gray-70);
|
--color-json-highlight: var(--prim-gray-70);
|
||||||
--color-code-background: var(--prim-gray-0);
|
--color-code-background: var(--prim-gray-0);
|
||||||
--color-code-background-readonly: var(--prim-gray-40);
|
--color-code-background-readonly: var(--prim-gray-40);
|
||||||
--color-code-lineHighlight: var(--prim-gray-40);
|
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
|
||||||
--color-code-foreground: var(--prim-gray-670);
|
--color-code-foreground: var(--prim-gray-670);
|
||||||
--color-code-caret: var(--prim-gray-420);
|
--color-code-caret: var(--prim-gray-820);
|
||||||
--color-code-selection: var(--prim-gray-120);
|
--color-code-selection: #0366d625;
|
||||||
--color-code-gutterBackground: var(--prim-gray-0);
|
--color-code-selection-highlight: #34d05840;
|
||||||
--color-code-gutterForeground: var(--prim-gray-320);
|
--color-code-gutter-background: var(--prim-gray-0);
|
||||||
--color-code-tags-comment: var(--prim-gray-420);
|
--color-code-gutter-foreground: var(--prim-gray-320);
|
||||||
|
--color-code-gutter-foreground-active: var(--prim-gray-670);
|
||||||
|
--color-code-indentation-marker: var(--prim-gray-70);
|
||||||
|
--color-code-indentation-marker-active: var(--prim-gray-200);
|
||||||
--color-line-break: var(--prim-gray-320);
|
--color-line-break: var(--prim-gray-320);
|
||||||
--color-code-line-break: var(--prim-color-secondary-tint-200);
|
--color-code-line-break: var(--prim-color-secondary-tint-200);
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
"@codemirror/lang-python": "^6.1.6",
|
"@codemirror/lang-python": "^6.1.6",
|
||||||
"@codemirror/language": "^6.10.1",
|
"@codemirror/language": "^6.10.1",
|
||||||
"@codemirror/lint": "^6.8.0",
|
"@codemirror/lint": "^6.8.0",
|
||||||
|
"@codemirror/search": "^6.5.6",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.26.3",
|
"@codemirror/view": "^6.26.3",
|
||||||
"@fontsource/open-sans": "^4.5.0",
|
"@fontsource/open-sans": "^4.5.0",
|
||||||
|
@ -39,6 +40,8 @@
|
||||||
"@n8n/codemirror-lang": "workspace:*",
|
"@n8n/codemirror-lang": "workspace:*",
|
||||||
"@n8n/codemirror-lang-sql": "^1.0.2",
|
"@n8n/codemirror-lang-sql": "^1.0.2",
|
||||||
"@n8n/permissions": "workspace:*",
|
"@n8n/permissions": "workspace:*",
|
||||||
|
"@replit/codemirror-indentation-markers": "^6.5.3",
|
||||||
|
"@typescript/vfs": "^1.6.0",
|
||||||
"@sentry/vue": "catalog:frontend",
|
"@sentry/vue": "catalog:frontend",
|
||||||
"@vue-flow/background": "^1.3.2",
|
"@vue-flow/background": "^1.3.2",
|
||||||
"@vue-flow/controls": "^1.1.2",
|
"@vue-flow/controls": "^1.1.2",
|
||||||
|
@ -52,6 +55,7 @@
|
||||||
"change-case": "^5.4.4",
|
"change-case": "^5.4.4",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"codemirror-lang-html-n8n": "^1.0.0",
|
"codemirror-lang-html-n8n": "^1.0.0",
|
||||||
|
"comlink": "^4.4.1",
|
||||||
"dateformat": "^3.0.3",
|
"dateformat": "^3.0.3",
|
||||||
"email-providers": "^2.0.1",
|
"email-providers": "^2.0.1",
|
||||||
"esprima-next": "5.8.4",
|
"esprima-next": "5.8.4",
|
||||||
|
@ -70,6 +74,7 @@
|
||||||
"qrcode.vue": "^3.3.4",
|
"qrcode.vue": "^3.3.4",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"timeago.js": "^4.0.2",
|
"timeago.js": "^4.0.2",
|
||||||
|
"typescript": "^5.5.2",
|
||||||
"uuid": "catalog:",
|
"uuid": "catalog:",
|
||||||
"v3-infinite-loading": "^1.2.2",
|
"v3-infinite-loading": "^1.2.2",
|
||||||
"vue": "catalog:frontend",
|
"vue": "catalog:frontend",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { IRestApiContext } from '@/Interface';
|
import type { IRestApiContext } from '@/Interface';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest, request } from '@/utils/apiUtils';
|
||||||
|
|
||||||
export interface TestDefinitionRecord {
|
export interface TestDefinitionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
annotationTag?: string | null;
|
||||||
|
mockedNodes?: Array<{ name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateTestDefinitionParams {
|
interface CreateTestDefinitionParams {
|
||||||
name: string;
|
name: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
|
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
|
||||||
evaluationWorkflowId?: string | null;
|
evaluationWorkflowId?: string | null;
|
||||||
annotationTagId?: string | null;
|
annotationTagId?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
mockedNodes?: Array<{ name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTestResponse {
|
export interface UpdateTestResponse {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
description: string | null;
|
description?: string | null;
|
||||||
annotationTag: string | null;
|
annotationTag?: string | null;
|
||||||
evaluationWorkflowId: string | null;
|
evaluationWorkflowId?: string | null;
|
||||||
annotationTagId: string | null;
|
annotationTagId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunRecord {
|
||||||
|
id: string;
|
||||||
|
testDefinitionId: string;
|
||||||
|
status: 'new' | 'running' | 'completed' | 'error';
|
||||||
|
metrics?: Record<string, number>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
runAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetTestRunParams {
|
||||||
|
testDefinitionId: string;
|
||||||
|
runId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteTestRunParams {
|
||||||
|
testDefinitionId: string;
|
||||||
|
runId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = '/evaluation/test-definitions';
|
const endpoint = '/evaluation/test-definitions';
|
||||||
|
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
|
||||||
|
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
|
||||||
|
|
||||||
export async function getTestDefinitions(context: IRestApiContext) {
|
export async function getTestDefinitions(
|
||||||
|
context: IRestApiContext,
|
||||||
|
params?: { workflowId?: string },
|
||||||
|
) {
|
||||||
|
let url = endpoint;
|
||||||
|
if (params?.workflowId) {
|
||||||
|
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
|
||||||
|
}
|
||||||
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
|
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
|
||||||
context,
|
context,
|
||||||
'GET',
|
'GET',
|
||||||
endpoint,
|
url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTestDefinition(context: IRestApiContext, id: string) {
|
export async function getTestDefinition(context: IRestApiContext, id: string) {
|
||||||
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
|
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTestDefinition(
|
export async function createTestDefinition(
|
||||||
|
@ -71,3 +107,125 @@ export async function updateTestDefinition(
|
||||||
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
|
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
|
||||||
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
|
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
export interface TestMetricRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
testDefinitionId: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTestMetricParams {
|
||||||
|
testDefinitionId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTestMetricParams {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
testDefinitionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteTestMetricParams {
|
||||||
|
testDefinitionId: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||||
|
return await makeRestApiRequest<TestMetricRecord[]>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
getMetricsEndpoint(testDefinitionId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTestMetric = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
testDefinitionId: string,
|
||||||
|
id: string,
|
||||||
|
) => {
|
||||||
|
return await makeRestApiRequest<TestMetricRecord>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
getMetricsEndpoint(testDefinitionId, id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTestMetric = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
params: CreateTestMetricParams,
|
||||||
|
) => {
|
||||||
|
return await makeRestApiRequest<TestMetricRecord>(
|
||||||
|
context,
|
||||||
|
'POST',
|
||||||
|
getMetricsEndpoint(params.testDefinitionId),
|
||||||
|
{ name: params.name },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTestMetric = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
params: UpdateTestMetricParams,
|
||||||
|
) => {
|
||||||
|
return await makeRestApiRequest<TestMetricRecord>(
|
||||||
|
context,
|
||||||
|
'PATCH',
|
||||||
|
getMetricsEndpoint(params.testDefinitionId, params.id),
|
||||||
|
{ name: params.name },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTestMetric = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
params: DeleteTestMetricParams,
|
||||||
|
) => {
|
||||||
|
return await makeRestApiRequest(
|
||||||
|
context,
|
||||||
|
'DELETE',
|
||||||
|
getMetricsEndpoint(params.testDefinitionId, params.id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
|
||||||
|
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
|
||||||
|
|
||||||
|
// Get all test runs for a test definition
|
||||||
|
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||||
|
return await makeRestApiRequest<TestRunRecord[]>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
getRunsEndpoint(testDefinitionId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get specific test run
|
||||||
|
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
|
||||||
|
return await makeRestApiRequest<TestRunRecord>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
getRunsEndpoint(params.testDefinitionId, params.runId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start a new test run
|
||||||
|
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
|
||||||
|
const response = await request({
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: context.baseUrl,
|
||||||
|
endpoint: `${endpoint}/${testDefinitionId}/run`,
|
||||||
|
headers: { 'push-ref': context.pushRef },
|
||||||
|
});
|
||||||
|
// CLI is returning the response without wrapping it in `data` key
|
||||||
|
return response as { success: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a test run
|
||||||
|
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
|
||||||
|
return await makeRestApiRequest<{ success: boolean }>(
|
||||||
|
context,
|
||||||
|
'DELETE',
|
||||||
|
getRunsEndpoint(params.testDefinitionId, params.runId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -1,32 +1,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
|
||||||
import { python } from '@codemirror/lang-python';
|
|
||||||
import type { LanguageSupport } from '@codemirror/language';
|
|
||||||
import type { Extension, Line } from '@codemirror/state';
|
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
|
||||||
import type { ViewUpdate } from '@codemirror/view';
|
import type { ViewUpdate } from '@codemirror/view';
|
||||||
import { EditorView } from '@codemirror/view';
|
|
||||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||||
import { format } from 'prettier';
|
import { format } from 'prettier';
|
||||||
import jsParser from 'prettier/plugins/babel';
|
import jsParser from 'prettier/plugins/babel';
|
||||||
import * as estree from 'prettier/plugins/estree';
|
import * as estree from 'prettier/plugins/estree';
|
||||||
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
|
|
||||||
import { CODE_NODE_TYPE } from '@/constants';
|
import { CODE_NODE_TYPE } from '@/constants';
|
||||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
|
||||||
|
import { useCodeEditor } from '@/composables/useCodeEditor';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import AskAI from './AskAI/AskAI.vue';
|
import AskAI from './AskAI/AskAI.vue';
|
||||||
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
|
|
||||||
import { useCompleter } from './completer';
|
|
||||||
import { CODE_PLACEHOLDERS } from './constants';
|
import { CODE_PLACEHOLDERS } from './constants';
|
||||||
import { useLinter } from './linter';
|
import { useLinter } from './linter';
|
||||||
import { codeNodeEditorTheme } from './theme';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: CodeExecutionMode;
|
mode: CodeExecutionMode;
|
||||||
|
@ -36,6 +28,7 @@ type Props = {
|
||||||
language?: CodeNodeEditorLanguage;
|
language?: CodeNodeEditorLanguage;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
@ -44,99 +37,57 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
language: 'javaScript',
|
language: 'javaScript',
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
rows: 4,
|
rows: 4,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string];
|
'update:modelValue': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const editor = ref(null) as Ref<EditorView | null>;
|
|
||||||
const languageCompartment = ref(new Compartment());
|
|
||||||
const dragAndDropCompartment = ref(new Compartment());
|
|
||||||
const linterCompartment = ref(new Compartment());
|
|
||||||
const isEditorHovered = ref(false);
|
|
||||||
const isEditorFocused = ref(false);
|
|
||||||
const tabs = ref(['code', 'ask-ai']);
|
const tabs = ref(['code', 'ask-ai']);
|
||||||
const activeTab = ref('code');
|
const activeTab = ref('code');
|
||||||
const hasChanges = ref(false);
|
|
||||||
const isLoadingAIResponse = ref(false);
|
const isLoadingAIResponse = ref(false);
|
||||||
const codeNodeEditorRef = ref<HTMLDivElement>();
|
const codeNodeEditorRef = ref<HTMLDivElement>();
|
||||||
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
|
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
|
||||||
|
const hasManualChanges = ref(false);
|
||||||
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
|
|
||||||
const { createLinter } = useLinter(() => props.mode, editor);
|
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const linter = useLinter(
|
||||||
|
() => props.mode,
|
||||||
|
() => props.language,
|
||||||
|
);
|
||||||
|
const extensions = computed(() => [linter.value]);
|
||||||
|
const placeholder = computed(() => CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '');
|
||||||
|
const dragAndDropEnabled = computed(() => {
|
||||||
|
return !props.isReadOnly;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { highlightLine, readEditorValue, editor } = useCodeEditor({
|
||||||
|
id: props.id,
|
||||||
|
editorRef: codeNodeEditorRef,
|
||||||
|
language: () => props.language,
|
||||||
|
languageParams: () => ({ mode: props.mode }),
|
||||||
|
editorValue: () => props.modelValue,
|
||||||
|
placeholder,
|
||||||
|
extensions,
|
||||||
|
isReadOnly: () => props.isReadOnly,
|
||||||
|
theme: {
|
||||||
|
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||||
|
minHeight: '20vh',
|
||||||
|
rows: props.rows,
|
||||||
|
},
|
||||||
|
onChange: onEditorUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
|
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
|
||||||
|
|
||||||
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
|
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
|
||||||
|
|
||||||
const { isReadOnly, language } = props;
|
|
||||||
const extensions: Extension[] = [
|
|
||||||
...readOnlyEditorExtensions,
|
|
||||||
EditorState.readOnly.of(isReadOnly),
|
|
||||||
EditorView.editable.of(!isReadOnly),
|
|
||||||
codeNodeEditorTheme({
|
|
||||||
isReadOnly,
|
|
||||||
maxHeight: props.fillParent ? '100%' : '40vh',
|
|
||||||
minHeight: '20vh',
|
|
||||||
rows: props.rows,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!isReadOnly) {
|
|
||||||
const linter = createLinter(language);
|
|
||||||
if (linter) {
|
|
||||||
extensions.push(linterCompartment.value.of(linter));
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions.push(
|
|
||||||
...writableEditorExtensions,
|
|
||||||
dragAndDropCompartment.value.of(dragAndDropExtension.value),
|
|
||||||
EditorView.domEventHandlers({
|
|
||||||
focus: () => {
|
|
||||||
isEditorFocused.value = true;
|
|
||||||
},
|
|
||||||
blur: () => {
|
|
||||||
isEditorFocused.value = false;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
EditorView.updateListener.of((viewUpdate) => {
|
|
||||||
if (!viewUpdate.docChanged) return;
|
|
||||||
|
|
||||||
trackCompletion(viewUpdate);
|
|
||||||
|
|
||||||
const value = editor.value?.state.doc.toString();
|
|
||||||
if (value) {
|
|
||||||
emit('update:modelValue', value);
|
|
||||||
}
|
|
||||||
hasChanges.value = true;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [languageSupport, ...otherExtensions] = languageExtensions.value;
|
|
||||||
extensions.push(languageCompartment.value.of(languageSupport), ...otherExtensions);
|
|
||||||
|
|
||||||
const state = EditorState.create({
|
|
||||||
doc: props.modelValue ?? placeholder.value,
|
|
||||||
extensions,
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.value = new EditorView({
|
|
||||||
parent: codeNodeEditorRef.value,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
// empty on first load, default param value
|
|
||||||
if (!props.modelValue) {
|
if (!props.modelValue) {
|
||||||
refreshPlaceholder();
|
|
||||||
emit('update:modelValue', placeholder.value);
|
emit('update:modelValue', placeholder.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -150,89 +101,12 @@ const askAiEnabled = computed(() => {
|
||||||
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
|
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
|
||||||
});
|
});
|
||||||
|
|
||||||
const placeholder = computed(() => {
|
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
|
||||||
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
|
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
|
||||||
});
|
emit('update:modelValue', placeholder.value);
|
||||||
|
|
||||||
const dragAndDropEnabled = computed(() => {
|
|
||||||
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
|
|
||||||
});
|
|
||||||
|
|
||||||
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
|
|
||||||
|
|
||||||
// eslint-disable-next-line vue/return-in-computed-property
|
|
||||||
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
|
|
||||||
switch (props.language) {
|
|
||||||
case 'javaScript':
|
|
||||||
return [javascript(), autocompletionExtension('javaScript')];
|
|
||||||
case 'python':
|
|
||||||
return [python(), autocompletionExtension('python')];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newValue) => {
|
|
||||||
if (!editor.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = editor.value.state.doc.toString();
|
|
||||||
if (current === newValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.value.dispatch({
|
|
||||||
changes: { from: 0, to: getCurrentEditorContent().length, insert: newValue },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.mode,
|
|
||||||
(_newMode, previousMode: CodeExecutionMode) => {
|
|
||||||
reloadLinter();
|
|
||||||
|
|
||||||
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[props.language]?.[previousMode]) {
|
|
||||||
refreshPlaceholder();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(dragAndDropExtension, (extension) => {
|
|
||||||
editor.value?.dispatch({
|
|
||||||
effects: dragAndDropCompartment.value.reconfigure(extension),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.language,
|
|
||||||
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
|
|
||||||
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[props.mode]) {
|
|
||||||
refreshPlaceholder();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [languageSupport] = languageExtensions.value;
|
|
||||||
editor.value?.dispatch({
|
|
||||||
effects: languageCompartment.value.reconfigure(languageSupport),
|
|
||||||
});
|
|
||||||
reloadLinter();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
watch(
|
|
||||||
askAiEnabled,
|
|
||||||
async (isEnabled) => {
|
|
||||||
if (isEnabled && !props.modelValue) {
|
|
||||||
emit('update:modelValue', placeholder.value);
|
|
||||||
}
|
|
||||||
await nextTick();
|
|
||||||
hasChanges.value = props.modelValue !== placeholder.value;
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
function getCurrentEditorContent() {
|
|
||||||
return editor.value?.state.doc.toString() ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
||||||
// Confirm dialog if leaving ask-ai tab during loading
|
// Confirm dialog if leaving ask-ai tab during loading
|
||||||
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
|
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
|
||||||
|
@ -243,69 +117,28 @@ async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmModal === 'confirm') {
|
return confirmModal === 'confirm';
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onReplaceCode(code: string) {
|
async function onAiReplaceCode(code: string) {
|
||||||
const formattedCode = await format(code, {
|
const formattedCode = await format(code, {
|
||||||
parser: 'babel',
|
parser: 'babel',
|
||||||
plugins: [jsParser, estree],
|
plugins: [jsParser, estree],
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.value?.dispatch({
|
emit('update:modelValue', formattedCode);
|
||||||
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
|
|
||||||
});
|
|
||||||
|
|
||||||
activeTab.value = 'code';
|
activeTab.value = 'code';
|
||||||
hasChanges.value = false;
|
hasManualChanges.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseOver(event: MouseEvent) {
|
function onEditorUpdate(viewUpdate: ViewUpdate) {
|
||||||
const fromElement = event.relatedTarget as HTMLElement;
|
trackCompletion(viewUpdate);
|
||||||
const containerRef = codeNodeEditorContainerRef.value;
|
hasManualChanges.value = true;
|
||||||
|
emit('update:modelValue', readEditorValue());
|
||||||
if (!containerRef?.contains(fromElement)) isEditorHovered.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseOut(event: MouseEvent) {
|
|
||||||
const fromElement = event.relatedTarget as HTMLElement;
|
|
||||||
const containerRef = codeNodeEditorContainerRef.value;
|
|
||||||
|
|
||||||
if (!containerRef?.contains(fromElement)) isEditorHovered.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadLinter() {
|
|
||||||
if (!editor.value) return;
|
|
||||||
|
|
||||||
const linter = createLinter(props.language);
|
|
||||||
if (linter) {
|
|
||||||
editor.value.dispatch({
|
|
||||||
effects: linterCompartment.value.reconfigure(linter),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshPlaceholder() {
|
|
||||||
if (!editor.value) return;
|
|
||||||
|
|
||||||
editor.value.dispatch({
|
|
||||||
changes: { from: 0, to: getCurrentEditorContent().length, insert: placeholder.value },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLine(lineNumber: number): Line | null {
|
|
||||||
try {
|
|
||||||
return editor.value?.state.doc.line(lineNumber) ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffApplied() {
|
function diffApplied() {
|
||||||
|
@ -315,25 +148,6 @@ function diffApplied() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightLine(lineNumber: number | 'final') {
|
|
||||||
if (!editor.value) return;
|
|
||||||
|
|
||||||
if (lineNumber === 'final') {
|
|
||||||
editor.value.dispatch({
|
|
||||||
selection: { anchor: (props.modelValue ?? getCurrentEditorContent()).length },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = getLine(lineNumber);
|
|
||||||
|
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
editor.value.dispatch({
|
|
||||||
selection: { anchor: line.from },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackCompletion(viewUpdate: ViewUpdate) {
|
function trackCompletion(viewUpdate: ViewUpdate) {
|
||||||
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
||||||
|
|
||||||
|
@ -342,7 +156,7 @@ function trackCompletion(viewUpdate: ViewUpdate) {
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error - undocumented fields
|
// @ts-expect-error - undocumented fields
|
||||||
const { fromA, toB } = viewUpdate?.changedRanges[0];
|
const { fromA, toB } = viewUpdate?.changedRanges[0];
|
||||||
const full = getCurrentEditorContent().slice(fromA, toB);
|
const full = viewUpdate.state.doc.slice(fromA, toB).toString();
|
||||||
const lastDotIndex = full.lastIndexOf('.');
|
const lastDotIndex = full.lastIndexOf('.');
|
||||||
|
|
||||||
let context = null;
|
let context = null;
|
||||||
|
@ -379,16 +193,19 @@ function onAiLoadEnd() {
|
||||||
async function onDrop(value: string, event: MouseEvent) {
|
async function onDrop(value: string, event: MouseEvent) {
|
||||||
if (!editor.value) return;
|
if (!editor.value) return;
|
||||||
|
|
||||||
await dropInCodeEditor(toRaw(editor.value), event, value);
|
const valueToInsert =
|
||||||
|
props.mode === 'runOnceForAllItems'
|
||||||
|
? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()')
|
||||||
|
: value;
|
||||||
|
|
||||||
|
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="codeNodeEditorContainerRef"
|
ref="codeNodeEditorContainerRef"
|
||||||
:class="['code-node-editor', $style['code-node-editor-container'], language]"
|
:class="['code-node-editor', $style['code-node-editor-container']]"
|
||||||
@mouseover="onMouseOver"
|
|
||||||
@mouseout="onMouseOut"
|
|
||||||
>
|
>
|
||||||
<el-tabs
|
<el-tabs
|
||||||
v-if="askAiEnabled"
|
v-if="askAiEnabled"
|
||||||
|
@ -433,8 +250,8 @@ async function onDrop(value: string, event: MouseEvent) {
|
||||||
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
|
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
|
||||||
<AskAI
|
<AskAI
|
||||||
:key="activeTab"
|
:key="activeTab"
|
||||||
:has-changes="hasChanges"
|
:has-changes="hasManualChanges"
|
||||||
@replace-code="onReplaceCode"
|
@replace-code="onAiReplaceCode"
|
||||||
@started-loading="onAiLoadStart"
|
@started-loading="onAiLoadStart"
|
||||||
@finished-loading="onAiLoadEnd"
|
@finished-loading="onAiLoadEnd"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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_SEVERITY: Diagnostic['severity'] = 'error';
|
||||||
|
|
||||||
export const DEFAULT_LINTER_DELAY_IN_MS = 300;
|
export const DEFAULT_LINTER_DELAY_IN_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
|
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import type { Diagnostic } from '@codemirror/lint';
|
import type { Diagnostic } from '@codemirror/lint';
|
||||||
import { linter } from '@codemirror/lint';
|
import { linter as codeMirrorLinter } from '@codemirror/lint';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import * as esprima from 'esprima-next';
|
import * as esprima from 'esprima-next';
|
||||||
import type { Node, MemberExpression } from 'estree';
|
import type { Node, MemberExpression } from 'estree';
|
||||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||||
import { toValue, type MaybeRefOrGetter } from 'vue';
|
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||||
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import {
|
import {
|
||||||
|
@ -17,17 +17,17 @@ import { walk } from './utils';
|
||||||
|
|
||||||
export const useLinter = (
|
export const useLinter = (
|
||||||
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||||
editor: MaybeRefOrGetter<EditorView | null>,
|
language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
|
||||||
) => {
|
) => {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
const linter = computed(() => {
|
||||||
function createLinter(language: CodeNodeEditorLanguage) {
|
switch (toValue(language)) {
|
||||||
switch (language) {
|
|
||||||
case 'javaScript':
|
case 'javaScript':
|
||||||
return linter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
}
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
function lintSource(editorView: EditorView): Diagnostic[] {
|
function lintSource(editorView: EditorView): Diagnostic[] {
|
||||||
const doc = editorView.state.doc.toString();
|
const doc = editorView.state.doc.toString();
|
||||||
|
@ -38,34 +38,7 @@ export const useLinter = (
|
||||||
try {
|
try {
|
||||||
ast = esprima.parseScript(script, { range: true });
|
ast = esprima.parseScript(script, { range: true });
|
||||||
} catch (syntaxError) {
|
} catch (syntaxError) {
|
||||||
let line;
|
return [];
|
||||||
|
|
||||||
try {
|
|
||||||
const lineAtError = editorView.state.doc.line(syntaxError.lineNumber - 1).text;
|
|
||||||
|
|
||||||
// optional chaining operators currently unsupported by esprima-next
|
|
||||||
if (['?.', ']?'].some((operator) => lineAtError.includes(operator))) return [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
line = editorView.state.doc.line(syntaxError.lineNumber);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
from: line.from,
|
|
||||||
to: line.to,
|
|
||||||
severity: DEFAULT_LINTER_SEVERITY,
|
|
||||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} catch {
|
|
||||||
/**
|
|
||||||
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
|
|
||||||
*/
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ast === null) return [];
|
if (ast === null) return [];
|
||||||
|
@ -118,7 +91,7 @@ export const useLinter = (
|
||||||
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
|
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
|
||||||
const [start, end] = getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
const varName = getText(node);
|
const varName = getText(editorView, node);
|
||||||
|
|
||||||
if (!varName) return;
|
if (!varName) return;
|
||||||
|
|
||||||
|
@ -250,7 +223,7 @@ export const useLinter = (
|
||||||
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
|
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
|
||||||
const [start, end] = getRange(node.property);
|
const [start, end] = getRange(node.property);
|
||||||
|
|
||||||
const method = getText(node.property);
|
const method = getText(editorView, node.property);
|
||||||
|
|
||||||
if (!method) return;
|
if (!method) return;
|
||||||
|
|
||||||
|
@ -444,7 +417,7 @@ export const useLinter = (
|
||||||
|
|
||||||
if (shadowStart && start > shadowStart) return; // skip shadow item
|
if (shadowStart && start > shadowStart) return; // skip shadow item
|
||||||
|
|
||||||
const varName = getText(node);
|
const varName = getText(editorView, node);
|
||||||
|
|
||||||
if (!varName) return;
|
if (!varName) return;
|
||||||
|
|
||||||
|
@ -489,7 +462,7 @@ export const useLinter = (
|
||||||
!['json', 'binary'].includes(node.property.name);
|
!['json', 'binary'].includes(node.property.name);
|
||||||
|
|
||||||
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
|
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
|
||||||
const varName = getText(node);
|
const varName = getText(editorView, node);
|
||||||
|
|
||||||
if (!varName) return;
|
if (!varName) return;
|
||||||
|
|
||||||
|
@ -636,19 +609,15 @@ export const useLinter = (
|
||||||
// helpers
|
// helpers
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
function getText(node: RangeNode) {
|
function getText(editorView: EditorView, node: RangeNode) {
|
||||||
const editorValue = toValue(editor);
|
|
||||||
|
|
||||||
if (!editorValue) return null;
|
|
||||||
|
|
||||||
const [start, end] = getRange(node);
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
return editorValue.state.doc.toString().slice(start, end);
|
return editorView.state.doc.toString().slice(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRange(node: RangeNode) {
|
function getRange(node: RangeNode) {
|
||||||
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createLinter };
|
return linter;
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,16 +32,62 @@ interface ThemeSettings {
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
highlightColors?: 'default' | 'html';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const codeNodeEditorTheme = ({
|
const codeEditorSyntaxHighlighting = syntaxHighlighting(
|
||||||
isReadOnly,
|
HighlightStyle.define([
|
||||||
minHeight,
|
{ tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
|
||||||
maxHeight,
|
{
|
||||||
rows,
|
tag: [
|
||||||
highlightColors,
|
tags.deleted,
|
||||||
}: ThemeSettings) => [
|
tags.character,
|
||||||
|
tags.macroName,
|
||||||
|
tags.definition(tags.name),
|
||||||
|
tags.definition(tags.variableName),
|
||||||
|
tags.atom,
|
||||||
|
tags.bool,
|
||||||
|
],
|
||||||
|
color: 'var(--color-code-tags-variable)',
|
||||||
|
},
|
||||||
|
{ tag: [tags.name, tags.propertyName], color: 'var(--color-code-tags-property)' },
|
||||||
|
{
|
||||||
|
tag: [tags.processingInstruction, tags.string, tags.inserted, tags.special(tags.string)],
|
||||||
|
color: 'var(--color-code-tags-string)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [tags.function(tags.variableName), tags.labelName],
|
||||||
|
color: 'var(--color-code-tags-function)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||||
|
color: 'var(--color-code-tags-constant)',
|
||||||
|
},
|
||||||
|
{ tag: [tags.className], color: 'var(--color-code-tags-class)' },
|
||||||
|
{
|
||||||
|
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
|
||||||
|
color: 'var(--color-code-tags-primitive)',
|
||||||
|
},
|
||||||
|
{ tag: [tags.typeName], color: 'var(--color-code-tags-type)' },
|
||||||
|
{ tag: [tags.operator, tags.operatorKeyword], color: 'var(--color-code-tags-keyword)' },
|
||||||
|
{
|
||||||
|
tag: [tags.url, tags.escape, tags.regexp, tags.link],
|
||||||
|
color: 'var(--color-code-tags-keyword)',
|
||||||
|
},
|
||||||
|
{ tag: [tags.meta, tags.comment, tags.lineComment], color: 'var(--color-code-tags-comment)' },
|
||||||
|
{ tag: tags.strong, fontWeight: 'bold' },
|
||||||
|
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||||
|
{ tag: tags.link, textDecoration: 'underline' },
|
||||||
|
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-code-tags-heading)' },
|
||||||
|
{ tag: tags.invalid, color: 'var(--color-code-tags-invalid)' },
|
||||||
|
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||||
|
{
|
||||||
|
tag: [tags.derefOperator, tags.special(tags.variableName), tags.variableName, tags.separator],
|
||||||
|
color: 'var(--color-code-foreground)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: ThemeSettings) => [
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
'font-size': BASE_STYLING.fontSize,
|
'font-size': BASE_STYLING.fontSize,
|
||||||
|
@ -54,13 +100,17 @@ export const codeNodeEditorTheme = ({
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
fontFamily: BASE_STYLING.fontFamily,
|
fontFamily: BASE_STYLING.fontFamily,
|
||||||
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
|
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
|
||||||
|
lineHeight: 'var(--font-line-height-xloose)',
|
||||||
|
paddingTop: 'var(--spacing-2xs)',
|
||||||
|
paddingBottom: 'var(--spacing-s)',
|
||||||
},
|
},
|
||||||
'.cm-cursor, .cm-dropCursor': {
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
borderLeftColor: 'var(--color-code-caret)',
|
borderLeftColor: 'var(--color-code-caret)',
|
||||||
},
|
},
|
||||||
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
|
'&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
backgroundColor: 'var(--color-code-selection)',
|
{
|
||||||
},
|
background: 'var(--color-code-selection)',
|
||||||
|
},
|
||||||
'&.cm-editor': {
|
'&.cm-editor': {
|
||||||
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
|
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
|
||||||
borderColor: 'var(--border-color-base)',
|
borderColor: 'var(--border-color-base)',
|
||||||
|
@ -75,13 +125,19 @@ export const codeNodeEditorTheme = ({
|
||||||
'.cm-activeLineGutter': {
|
'.cm-activeLineGutter': {
|
||||||
backgroundColor: 'var(--color-code-lineHighlight)',
|
backgroundColor: 'var(--color-code-lineHighlight)',
|
||||||
},
|
},
|
||||||
|
'.cm-lineNumbers .cm-activeLineGutter': {
|
||||||
|
color: 'var(--color-code-gutter-foreground-active)',
|
||||||
|
},
|
||||||
'.cm-gutters': {
|
'.cm-gutters': {
|
||||||
backgroundColor: isReadOnly
|
backgroundColor: isReadOnly
|
||||||
? 'var(--color-code-background-readonly)'
|
? 'var(--color-code-background-readonly)'
|
||||||
: 'var(--color-code-gutterBackground)',
|
: 'var(--color-code-gutter-background)',
|
||||||
color: 'var(--color-code-gutterForeground)',
|
color: 'var(--color-code-gutter-foreground)',
|
||||||
|
border: '0',
|
||||||
borderRadius: 'var(--border-radius-base)',
|
borderRadius: 'var(--border-radius-base)',
|
||||||
borderRightColor: 'var(--border-color-base)',
|
},
|
||||||
|
'.cm-gutterElement': {
|
||||||
|
padding: 0,
|
||||||
},
|
},
|
||||||
'.cm-tooltip': {
|
'.cm-tooltip': {
|
||||||
maxWidth: BASE_STYLING.tooltip.maxWidth,
|
maxWidth: BASE_STYLING.tooltip.maxWidth,
|
||||||
|
@ -92,11 +148,30 @@ export const codeNodeEditorTheme = ({
|
||||||
maxHeight: maxHeight ?? '100%',
|
maxHeight: maxHeight ?? '100%',
|
||||||
...(isReadOnly
|
...(isReadOnly
|
||||||
? {}
|
? {}
|
||||||
: { minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto' }),
|
: {
|
||||||
|
minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'.cm-lineNumbers .cm-gutterElement': {
|
||||||
|
padding: '0 var(--spacing-5xs) 0 var(--spacing-2xs)',
|
||||||
},
|
},
|
||||||
'.cm-gutter,.cm-content': {
|
'.cm-gutter,.cm-content': {
|
||||||
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
|
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
|
||||||
},
|
},
|
||||||
|
'.cm-foldGutter': {
|
||||||
|
width: '16px',
|
||||||
|
},
|
||||||
|
'.cm-fold-marker': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.3s ease',
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter .cm-fold-marker, .cm-gutters:hover .cm-fold-marker': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
'.cm-diagnosticAction': {
|
'.cm-diagnosticAction': {
|
||||||
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
|
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
|
@ -106,103 +181,81 @@ export const codeNodeEditorTheme = ({
|
||||||
cursor: BASE_STYLING.diagnosticButton.cursor,
|
cursor: BASE_STYLING.diagnosticButton.cursor,
|
||||||
},
|
},
|
||||||
'.cm-diagnostic-error': {
|
'.cm-diagnostic-error': {
|
||||||
backgroundColor: 'var(--color-background-base)',
|
backgroundColor: 'var(--color-infobox-background)',
|
||||||
},
|
},
|
||||||
'.cm-diagnosticText': {
|
'.cm-diagnosticText': {
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
color: 'var(--color-text-base)',
|
color: 'var(--color-text-base)',
|
||||||
},
|
},
|
||||||
|
'.cm-diagnosticDocs': {
|
||||||
|
fontSize: 'var(--font-size-2xs)',
|
||||||
|
},
|
||||||
|
'.cm-foldPlaceholder': {
|
||||||
|
color: 'var(--color-text-base)',
|
||||||
|
backgroundColor: 'var(--color-background-base)',
|
||||||
|
border: 'var(--border-base)',
|
||||||
|
},
|
||||||
|
'.cm-selectionMatch': {
|
||||||
|
background: 'var(--color-code-selection-highlight)',
|
||||||
|
},
|
||||||
|
'.cm-selectionMatch-main': {
|
||||||
|
background: 'var(--color-code-selection-highlight)',
|
||||||
|
},
|
||||||
|
'.cm-matchingBracket': {
|
||||||
|
background: 'var(--color-code-selection)',
|
||||||
|
},
|
||||||
|
'.cm-completionMatchedText': {
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'var(--color-autocomplete-item-selected)',
|
||||||
|
},
|
||||||
|
'.cm-faded > span': {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
'.cm-panel.cm-search': {
|
||||||
|
padding: 'var(--spacing-4xs) var(--spacing-2xs)',
|
||||||
|
},
|
||||||
|
'.cm-panels': {
|
||||||
|
background: 'var(--color-background-light)',
|
||||||
|
color: 'var(--color-text-base)',
|
||||||
|
},
|
||||||
|
'.cm-panels-bottom': {
|
||||||
|
borderTop: 'var(--border-base)',
|
||||||
|
},
|
||||||
|
'.cm-textfield': {
|
||||||
|
color: 'var(--color-text-dark)',
|
||||||
|
background: 'var(--color-foreground-xlight)',
|
||||||
|
borderRadius: 'var(--border-radius-base)',
|
||||||
|
border: 'var(--border-base)',
|
||||||
|
fontSize: '90%',
|
||||||
|
},
|
||||||
|
'.cm-textfield:focus': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: 'var(--color-secondary)',
|
||||||
|
},
|
||||||
|
'.cm-panel button': {
|
||||||
|
color: 'var(--color-text-base)',
|
||||||
|
},
|
||||||
|
'.cm-panel input[type="checkbox"]': {
|
||||||
|
border: 'var(--border-base)',
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-panel input[type="checkbox"]:hover': {
|
||||||
|
border: 'var(--border-base)',
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-panel.cm-search label': {
|
||||||
|
fontSize: '90%',
|
||||||
|
},
|
||||||
|
'.cm-button': {
|
||||||
|
outline: 'none',
|
||||||
|
border: 'var(--border-base)',
|
||||||
|
color: 'var(--color-text-dark)',
|
||||||
|
backgroundColor: 'var(--color-foreground-xlight)',
|
||||||
|
backgroundImage: 'none',
|
||||||
|
borderRadius: 'var(--border-radius-base)',
|
||||||
|
fontSize: '90%',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
highlightColors === 'html'
|
codeEditorSyntaxHighlighting,
|
||||||
? syntaxHighlighting(
|
|
||||||
HighlightStyle.define([
|
|
||||||
{ tag: tags.keyword, color: '#c678dd' },
|
|
||||||
{
|
|
||||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
|
||||||
color: '#e06c75',
|
|
||||||
},
|
|
||||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
|
|
||||||
{
|
|
||||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
|
||||||
color: '#d19a66',
|
|
||||||
},
|
|
||||||
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
|
|
||||||
{
|
|
||||||
tag: [
|
|
||||||
tags.typeName,
|
|
||||||
tags.className,
|
|
||||||
tags.number,
|
|
||||||
tags.changed,
|
|
||||||
tags.annotation,
|
|
||||||
tags.modifier,
|
|
||||||
tags.self,
|
|
||||||
tags.namespace,
|
|
||||||
],
|
|
||||||
color: '#e06c75',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: [
|
|
||||||
tags.operator,
|
|
||||||
tags.operatorKeyword,
|
|
||||||
tags.url,
|
|
||||||
tags.escape,
|
|
||||||
tags.regexp,
|
|
||||||
tags.link,
|
|
||||||
tags.special(tags.string),
|
|
||||||
],
|
|
||||||
color: '#56b6c2',
|
|
||||||
},
|
|
||||||
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
|
|
||||||
{ tag: tags.strong, fontWeight: 'bold' },
|
|
||||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
|
||||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
|
||||||
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
|
|
||||||
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
|
|
||||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
|
|
||||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
|
|
||||||
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
: syntaxHighlighting(
|
|
||||||
HighlightStyle.define([
|
|
||||||
{
|
|
||||||
tag: tags.comment,
|
|
||||||
color: 'var(--color-code-tags-comment)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: [tags.string, tags.special(tags.brace)],
|
|
||||||
color: 'var(--color-code-tags-string)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: [tags.number, tags.self, tags.bool, tags.null],
|
|
||||||
color: 'var(--color-code-tags-primitive)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: tags.keyword,
|
|
||||||
color: 'var(--color-code-tags-keyword)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: tags.operator,
|
|
||||||
color: 'var(--color-code-tags-operator)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: [
|
|
||||||
tags.variableName,
|
|
||||||
tags.propertyName,
|
|
||||||
tags.attributeName,
|
|
||||||
tags.regexp,
|
|
||||||
tags.className,
|
|
||||||
tags.typeName,
|
|
||||||
],
|
|
||||||
color: 'var(--color-code-tags-variable)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: [
|
|
||||||
tags.definition(tags.typeName),
|
|
||||||
tags.definition(tags.propertyName),
|
|
||||||
tags.function(tags.variableName),
|
|
||||||
],
|
|
||||||
color: 'var(--color-code-tags-definition)',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -245,7 +245,6 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
:global(.el-dialog__body) {
|
:global(.el-dialog__body) {
|
||||||
background-color: var(--color-expression-editor-modal-background);
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: var(--spacing-s);
|
padding: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,20 +7,14 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { forceParse } from '@/utils/forceParse';
|
import { forceParse } from '@/utils/forceParse';
|
||||||
import { completionStatus } from '@codemirror/autocomplete';
|
|
||||||
import { inputTheme } from './theme';
|
import { inputTheme } from './theme';
|
||||||
|
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import {
|
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -41,24 +35,7 @@ const emit = defineEmits<{
|
||||||
const root = ref<HTMLElement>();
|
const root = ref<HTMLElement>();
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
inputTheme(props.isReadOnly),
|
inputTheme(props.isReadOnly),
|
||||||
Prec.highest(
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
keymap.of([
|
|
||||||
...tabKeyMap(),
|
|
||||||
...historyKeyMap,
|
|
||||||
...enterKeyMap,
|
|
||||||
...autocompleteKeyMap,
|
|
||||||
{
|
|
||||||
any: (view, event) => {
|
|
||||||
if (event.key === 'Escape' && completionStatus(view.state) === null) {
|
|
||||||
event.stopPropagation();
|
|
||||||
emit('close');
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
n8nLang(),
|
n8nLang(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
mappingDropCursor(),
|
mappingDropCursor(),
|
||||||
|
@ -66,7 +43,7 @@ const extensions = computed(() => [
|
||||||
history(),
|
history(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||||
infoBoxTooltips(),
|
infoBoxTooltips(),
|
||||||
]);
|
]);
|
||||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||||
|
|
|
@ -11,11 +11,16 @@ import { useRootStore } from '@/stores/root.store';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { renderComponent } from '@/__tests__/render';
|
import { renderComponent } from '@/__tests__/render';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
vi.mock('@/composables/useToast', () => ({
|
vi.mock('@/composables/useToast', () => ({
|
||||||
useToast: vi.fn(),
|
useToast: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => ({
|
||||||
|
useTelemetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/stores/settings.store', () => ({
|
vi.mock('@/stores/settings.store', () => ({
|
||||||
useSettingsStore: vi.fn(),
|
useSettingsStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
@ -55,7 +60,17 @@ const assertUserCanClaimCredits = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertUserClaimedCredits = () => {
|
const assertUserClaimedCredits = () => {
|
||||||
expect(screen.getByText('Claimed 100 free OpenAI API credits')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Claimed 100 free OpenAI API credits! Please note these free credits are only for the following models:',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('FreeAiCreditsCallout', () => {
|
describe('FreeAiCreditsCallout', () => {
|
||||||
|
@ -86,7 +101,7 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
(usePostHog as any).mockReturnValue({
|
(usePostHog as any).mockReturnValue({
|
||||||
isFeatureEnabled: vi.fn().mockReturnValue(true),
|
getVariant: vi.fn().mockReturnValue('variant'),
|
||||||
});
|
});
|
||||||
|
|
||||||
(useProjectsStore as any).mockReturnValue({
|
(useProjectsStore as any).mockReturnValue({
|
||||||
|
@ -100,6 +115,10 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
(useToast as any).mockReturnValue({
|
(useToast as any).mockReturnValue({
|
||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(useTelemetry as any).mockReturnValue({
|
||||||
|
track: vi.fn(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should shows the claim callout when the user can claim credits', () => {
|
it('should shows the claim callout when the user can claim credits', () => {
|
||||||
|
@ -120,6 +139,7 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
await fireEvent.click(claimButton);
|
await fireEvent.click(claimButton);
|
||||||
|
|
||||||
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
|
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith('User claimed OpenAI credits');
|
||||||
assertUserClaimedCredits();
|
assertUserClaimedCredits();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -150,7 +170,7 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
|
|
||||||
it('should not be able to claim credits if user it is not in experiment', async () => {
|
it('should not be able to claim credits if user it is not in experiment', async () => {
|
||||||
(usePostHog as any).mockReturnValue({
|
(usePostHog as any).mockReturnValue({
|
||||||
isFeatureEnabled: vi.fn().mockReturnValue(false),
|
getVariant: vi.fn().mockReturnValue('control'),
|
||||||
});
|
});
|
||||||
|
|
||||||
renderComponent(FreeAiCreditsCallout);
|
renderComponent(FreeAiCreditsCallout);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { AI_CREDITS_EXPERIMENT } from '@/constants';
|
import { AI_CREDITS_EXPERIMENT } from '@/constants';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
@ -9,8 +10,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||||
const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
|
|
||||||
|
|
||||||
const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';
|
const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';
|
||||||
|
|
||||||
|
@ -27,11 +27,12 @@ const showSuccessCallout = ref(false);
|
||||||
const claimingCredits = ref(false);
|
const claimingCredits = ref(false);
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const postHogStore = usePostHog();
|
const posthogStore = usePostHog();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
@ -57,7 +58,7 @@ const userCanClaimOpenAiCredits = computed(() => {
|
||||||
return (
|
return (
|
||||||
settingsStore.isAiCreditsEnabled &&
|
settingsStore.isAiCreditsEnabled &&
|
||||||
activeNodeHasOpenAiApiCredential.value &&
|
activeNodeHasOpenAiApiCredential.value &&
|
||||||
postHogStore.isFeatureEnabled(AI_CREDITS_EXPERIMENT.name) &&
|
posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === AI_CREDITS_EXPERIMENT.variant &&
|
||||||
!userHasOpenAiCredentialAlready.value &&
|
!userHasOpenAiCredentialAlready.value &&
|
||||||
!userHasClaimedAiCreditsAlready.value
|
!userHasClaimedAiCreditsAlready.value
|
||||||
);
|
);
|
||||||
|
@ -73,6 +74,8 @@ const onClaimCreditsClicked = async () => {
|
||||||
usersStore.currentUser.settings.userClaimedAiCredits = true;
|
usersStore.currentUser.settings.userClaimedAiCredits = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
telemetry.track('User claimed OpenAI credits');
|
||||||
|
|
||||||
showSuccessCallout.value = true;
|
showSuccessCallout.value = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(
|
toast.showError(
|
||||||
|
@ -108,11 +111,16 @@ const onClaimCreditsClicked = async () => {
|
||||||
</template>
|
</template>
|
||||||
</n8n-callout>
|
</n8n-callout>
|
||||||
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
|
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
|
||||||
{{
|
<n8n-text>
|
||||||
i18n.baseText('freeAi.credits.callout.success.title', {
|
{{
|
||||||
interpolate: { credits: settingsStore.aiCreditsQuota },
|
i18n.baseText('freeAi.credits.callout.success.title.part1', {
|
||||||
})
|
interpolate: { credits: settingsStore.aiCreditsQuota },
|
||||||
}}
|
})
|
||||||
|
}}</n8n-text
|
||||||
|
>
|
||||||
|
<n8n-text :bold="true">
|
||||||
|
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
|
||||||
|
>
|
||||||
</n8n-callout>
|
</n8n-callout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -22,19 +22,14 @@ import htmlParser from 'prettier/plugins/html';
|
||||||
import cssParser from 'prettier/plugins/postcss';
|
import cssParser from 'prettier/plugins/postcss';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
|
||||||
|
|
||||||
import { htmlEditorEventBus } from '@/event-bus';
|
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
|
import { htmlEditorEventBus } from '@/event-bus';
|
||||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import {
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import type { Range, Section } from './types';
|
import type { Range, Section } from './types';
|
||||||
import { nonTakenRanges } from './utils';
|
import { nonTakenRanges } from './utils';
|
||||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
@ -67,16 +62,13 @@ const extensions = computed(() => [
|
||||||
),
|
),
|
||||||
autoCloseTags,
|
autoCloseTags,
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
Prec.highest(
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
|
|
||||||
),
|
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
codeNodeEditorTheme({
|
codeEditorTheme({
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: props.fullscreen ? '100%' : '40vh',
|
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
rows: props.rows,
|
rows: props.rows,
|
||||||
highlightColors: 'html',
|
|
||||||
}),
|
}),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
|
|
|
@ -97,7 +97,7 @@ onMounted(() => {
|
||||||
extensions: [
|
extensions: [
|
||||||
EditorState.readOnly.of(true),
|
EditorState.readOnly.of(true),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||||
...props.extensions,
|
...props.extensions,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -7,12 +7,7 @@ import { computed, ref, watch } from 'vue';
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import {
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
|
@ -42,9 +37,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const root = ref<HTMLElement>();
|
const root = ref<HTMLElement>();
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
Prec.highest(
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
keymap.of([...tabKeyMap(false), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
|
|
||||||
),
|
|
||||||
n8nLang(),
|
n8nLang(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
|
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { history, toggleComment } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { foldGutter, indentOnInput } from '@codemirror/language';
|
import { foldGutter, indentOnInput } from '@codemirror/language';
|
||||||
import { lintGutter } from '@codemirror/lint';
|
import { lintGutter } from '@codemirror/lint';
|
||||||
|
@ -16,14 +16,9 @@ import {
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import {
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -85,7 +80,7 @@ const extensions = computed(() => {
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorState.readOnly.of(props.isReadOnly),
|
EditorState.readOnly.of(props.isReadOnly),
|
||||||
codeNodeEditorTheme({
|
codeEditorTheme({
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: props.fillParent ? '100%' : '40vh',
|
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
|
@ -96,15 +91,7 @@ const extensions = computed(() => {
|
||||||
if (!props.isReadOnly) {
|
if (!props.isReadOnly) {
|
||||||
extensionsToApply.push(
|
extensionsToApply.push(
|
||||||
history(),
|
history(),
|
||||||
Prec.highest(
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
keymap.of([
|
|
||||||
...tabKeyMap(),
|
|
||||||
...enterKeyMap,
|
|
||||||
...historyKeyMap,
|
|
||||||
...autocompleteKeyMap,
|
|
||||||
{ key: 'Mod-/', run: toggleComment },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
lintGutter(),
|
lintGutter(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
|
|
|
@ -15,15 +15,10 @@ import {
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
|
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
import {
|
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -48,7 +43,7 @@ const extensions = computed(() => {
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorState.readOnly.of(props.isReadOnly),
|
EditorState.readOnly.of(props.isReadOnly),
|
||||||
codeNodeEditorTheme({
|
codeEditorTheme({
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: props.fillParent ? '100%' : '40vh',
|
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
|
@ -58,9 +53,7 @@ const extensions = computed(() => {
|
||||||
if (!props.isReadOnly) {
|
if (!props.isReadOnly) {
|
||||||
extensionsToApply.push(
|
extensionsToApply.push(
|
||||||
history(),
|
history(),
|
||||||
Prec.highest(
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
|
|
||||||
),
|
|
||||||
createLinter(jsonParseLinter()),
|
createLinter(jsonParseLinter()),
|
||||||
lintGutter(),
|
lintGutter(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
|
|
|
@ -43,6 +43,25 @@ const executionToReturnTo = ref('');
|
||||||
const dirtyState = ref(false);
|
const dirtyState = ref(false);
|
||||||
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
|
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
|
||||||
|
|
||||||
|
// Track the routes that are used for the tabs
|
||||||
|
// This is used to determine which tab to show when the route changes
|
||||||
|
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
|
||||||
|
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
|
||||||
|
const testDefinitionRoutes: VIEWS[] = [
|
||||||
|
VIEWS.TEST_DEFINITION,
|
||||||
|
VIEWS.TEST_DEFINITION_EDIT,
|
||||||
|
VIEWS.TEST_DEFINITION_RUNS,
|
||||||
|
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
|
||||||
|
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||||
|
|
||||||
|
const executionRoutes: VIEWS[] = [
|
||||||
|
VIEWS.EXECUTION_HOME,
|
||||||
|
VIEWS.WORKFLOW_EXECUTIONS,
|
||||||
|
VIEWS.EXECUTION_PREVIEW,
|
||||||
|
];
|
||||||
const tabBarItems = computed(() => {
|
const tabBarItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
|
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
|
||||||
|
@ -92,24 +111,30 @@ onMounted(async () => {
|
||||||
syncTabsWithRoute(route);
|
syncTabsWithRoute(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isViewRoute(name: unknown): name is VIEWS {
|
||||||
|
return (
|
||||||
|
typeof name === 'string' &&
|
||||||
|
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
|
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
|
||||||
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) {
|
// Map route types to their corresponding tab in the header
|
||||||
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
|
const routeTabMapping = [
|
||||||
}
|
{ routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
|
||||||
if (
|
{ routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
|
||||||
to.name === VIEWS.EXECUTION_HOME ||
|
{ routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
|
||||||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
|
];
|
||||||
to.name === VIEWS.EXECUTION_PREVIEW
|
|
||||||
) {
|
// Update the active tab based on the current route
|
||||||
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
|
if (to.name && isViewRoute(to.name)) {
|
||||||
} else if (
|
const matchingTab = routeTabMapping.find(({ routes }) => routes.includes(to.name as VIEWS));
|
||||||
to.name === VIEWS.WORKFLOW ||
|
if (matchingTab) {
|
||||||
to.name === VIEWS.NEW_WORKFLOW ||
|
activeHeaderTab.value = matchingTab.tab;
|
||||||
to.name === VIEWS.EXECUTION_DEBUG
|
}
|
||||||
) {
|
|
||||||
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the current workflow ID, but only if it's not a new workflow
|
||||||
if (to.params.name !== 'new' && typeof to.params.name === 'string') {
|
if (to.params.name !== 'new' && typeof to.params.name === 'string') {
|
||||||
workflowToReturnTo.value = to.params.name;
|
workflowToReturnTo.value = to.params.name;
|
||||||
}
|
}
|
||||||
|
|
|
@ -801,7 +801,6 @@ $--header-spacing: 20px;
|
||||||
color: $custom-font-dark;
|
color: $custom-font-dark;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 150px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.activator {
|
.activator {
|
||||||
|
@ -848,6 +847,14 @@ $--header-spacing: 20px;
|
||||||
gap: var(--spacing-m);
|
gap: var(--spacing-m);
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mixins.breakpoint('xs-only') {
|
||||||
|
.name {
|
||||||
|
:deep(input) {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
|
|
@ -476,6 +476,10 @@ const shortPath = computed<string>(() => {
|
||||||
return short.join('.');
|
return short.join('.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parameterId = computed(() => {
|
||||||
|
return `${node.value?.id ?? crypto.randomUUID()}${props.path}`;
|
||||||
|
});
|
||||||
|
|
||||||
const isResourceLocatorParameter = computed<boolean>(() => {
|
const isResourceLocatorParameter = computed<boolean>(() => {
|
||||||
return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
|
return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
|
||||||
});
|
});
|
||||||
|
@ -1092,21 +1096,20 @@ onUpdated(async () => {
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<el-dialog
|
<el-dialog
|
||||||
|
width="calc(100% - var(--spacing-3xl))"
|
||||||
|
:class="$style.modal"
|
||||||
:model-value="codeEditDialogVisible"
|
:model-value="codeEditDialogVisible"
|
||||||
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
|
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
|
||||||
width="80%"
|
|
||||||
:title="`${i18n.baseText('codeEdit.edit')} ${i18n
|
:title="`${i18n.baseText('codeEdit.edit')} ${i18n
|
||||||
.nodeText()
|
.nodeText()
|
||||||
.inputLabelDisplayName(parameter, path)}`"
|
.inputLabelDisplayName(parameter, path)}`"
|
||||||
:before-close="closeCodeEditDialog"
|
:before-close="closeCodeEditDialog"
|
||||||
data-test-id="code-editor-fullscreen"
|
data-test-id="code-editor-fullscreen"
|
||||||
>
|
>
|
||||||
<div
|
<div class="ignore-key-press-canvas code-edit-dialog">
|
||||||
:key="codeEditDialogVisible.toString()"
|
|
||||||
class="ignore-key-press-canvas code-edit-dialog"
|
|
||||||
>
|
|
||||||
<CodeNodeEditor
|
<CodeNodeEditor
|
||||||
v-if="editorType === 'codeNodeEditor'"
|
v-if="editorType === 'codeNodeEditor'"
|
||||||
|
:id="parameterId"
|
||||||
:mode="codeEditorMode"
|
:mode="codeEditorMode"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:default-value="parameter.default"
|
:default-value="parameter.default"
|
||||||
|
@ -1116,7 +1119,7 @@ onUpdated(async () => {
|
||||||
@update:model-value="valueChangedDebounced"
|
@update:model-value="valueChangedDebounced"
|
||||||
/>
|
/>
|
||||||
<HtmlEditor
|
<HtmlEditor
|
||||||
v-else-if="editorType === 'htmlEditor'"
|
v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
|
@ -1126,7 +1129,7 @@ onUpdated(async () => {
|
||||||
@update:model-value="valueChangedDebounced"
|
@update:model-value="valueChangedDebounced"
|
||||||
/>
|
/>
|
||||||
<SqlEditor
|
<SqlEditor
|
||||||
v-else-if="editorType === 'sqlEditor'"
|
v-else-if="editorType === 'sqlEditor' && !codeEditDialogVisible"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:dialect="getArgument('sqlDialect')"
|
:dialect="getArgument('sqlDialect')"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
|
@ -1135,7 +1138,7 @@ onUpdated(async () => {
|
||||||
@update:model-value="valueChangedDebounced"
|
@update:model-value="valueChangedDebounced"
|
||||||
/>
|
/>
|
||||||
<JsEditor
|
<JsEditor
|
||||||
v-else-if="editorType === 'jsEditor'"
|
v-else-if="editorType === 'jsEditor' && !codeEditDialogVisible"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
|
@ -1145,7 +1148,7 @@ onUpdated(async () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
v-else-if="parameter.type === 'json'"
|
v-else-if="parameter.type === 'json' && !codeEditDialogVisible"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
|
@ -1166,8 +1169,8 @@ onUpdated(async () => {
|
||||||
></TextEdit>
|
></TextEdit>
|
||||||
|
|
||||||
<CodeNodeEditor
|
<CodeNodeEditor
|
||||||
v-if="editorType === 'codeNodeEditor' && isCodeNode"
|
v-if="editorType === 'codeNodeEditor' && isCodeNode && !codeEditDialogVisible"
|
||||||
:key="'code-' + codeEditDialogVisible.toString()"
|
:id="parameterId"
|
||||||
:mode="codeEditorMode"
|
:mode="codeEditorMode"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:default-value="parameter.default"
|
:default-value="parameter.default"
|
||||||
|
@ -1191,8 +1194,7 @@ onUpdated(async () => {
|
||||||
</CodeNodeEditor>
|
</CodeNodeEditor>
|
||||||
|
|
||||||
<HtmlEditor
|
<HtmlEditor
|
||||||
v-else-if="editorType === 'htmlEditor'"
|
v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
|
||||||
:key="'html-' + codeEditDialogVisible.toString()"
|
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
|
@ -1214,7 +1216,6 @@ onUpdated(async () => {
|
||||||
|
|
||||||
<SqlEditor
|
<SqlEditor
|
||||||
v-else-if="editorType === 'sqlEditor'"
|
v-else-if="editorType === 'sqlEditor'"
|
||||||
:key="'sql-' + codeEditDialogVisible.toString()"
|
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:dialect="getArgument('sqlDialect')"
|
:dialect="getArgument('sqlDialect')"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
|
@ -1235,7 +1236,6 @@ onUpdated(async () => {
|
||||||
|
|
||||||
<JsEditor
|
<JsEditor
|
||||||
v-else-if="editorType === 'jsEditor'"
|
v-else-if="editorType === 'jsEditor'"
|
||||||
:key="'js-' + codeEditDialogVisible.toString()"
|
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:is-read-only="isReadOnly || editorIsReadOnly"
|
:is-read-only="isReadOnly || editorIsReadOnly"
|
||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
|
@ -1257,7 +1257,6 @@ onUpdated(async () => {
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
v-else-if="parameter.type === 'json'"
|
v-else-if="parameter.type === 'json'"
|
||||||
:key="'json-' + codeEditDialogVisible.toString()"
|
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
|
@ -1278,6 +1277,7 @@ onUpdated(async () => {
|
||||||
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
|
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
|
||||||
<CodeNodeEditor
|
<CodeNodeEditor
|
||||||
v-if="!codeEditDialogVisible"
|
v-if="!codeEditDialogVisible"
|
||||||
|
:id="parameterId"
|
||||||
:mode="codeEditorMode"
|
:mode="codeEditorMode"
|
||||||
:model-value="modelValueString"
|
:model-value="modelValueString"
|
||||||
:language="editorLanguage"
|
:language="editorLanguage"
|
||||||
|
@ -1630,8 +1630,8 @@ onUpdated(async () => {
|
||||||
|
|
||||||
.textarea-modal-opener {
|
.textarea-modal-opener {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 1px;
|
||||||
bottom: 0;
|
bottom: 1px;
|
||||||
background-color: var(--color-code-background);
|
background-color: var(--color-code-background);
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
line-height: 9px;
|
line-height: 9px;
|
||||||
|
@ -1639,6 +1639,8 @@ onUpdated(async () => {
|
||||||
border-top-left-radius: var(--border-radius-base);
|
border-top-left-radius: var(--border-radius-base);
|
||||||
border-bottom-right-radius: var(--border-radius-base);
|
border-bottom-right-radius: var(--border-radius-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 9px !important;
|
width: 9px !important;
|
||||||
|
@ -1660,7 +1662,7 @@ onUpdated(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-edit-dialog {
|
.code-edit-dialog {
|
||||||
height: 70vh;
|
height: 100%;
|
||||||
|
|
||||||
.code-node-editor {
|
.code-node-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -1668,7 +1670,25 @@ onUpdated(async () => {
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="css" module>
|
||||||
|
.modal {
|
||||||
|
--dialog-close-top: var(--spacing-m);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: clip;
|
||||||
|
height: calc(100% - var(--spacing-4xl));
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
:global(.el-dialog__header) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-dialog__body) {
|
||||||
|
height: calc(100% - var(--spacing-3xl));
|
||||||
|
padding: var(--spacing-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tipVisible {
|
.tipVisible {
|
||||||
--input-border-bottom-left-radius: 0;
|
--input-border-bottom-left-radius: 0;
|
||||||
--input-border-bottom-right-radius: 0;
|
--input-border-bottom-right-radius: 0;
|
||||||
|
|
|
@ -63,7 +63,6 @@ const state = reactive({
|
||||||
value: {},
|
value: {},
|
||||||
matchingColumns: [] as string[],
|
matchingColumns: [] as string[],
|
||||||
schema: [] as ResourceMapperField[],
|
schema: [] as ResourceMapperField[],
|
||||||
ignoreTypeMismatchErrors: false,
|
|
||||||
attemptToConvertTypes: false,
|
attemptToConvertTypes: false,
|
||||||
// This should always be true if `showTypeConversionOptions` is provided
|
// This should always be true if `showTypeConversionOptions` is provided
|
||||||
// It's used to avoid accepting any value as string without casting it
|
// It's used to avoid accepting any value as string without casting it
|
||||||
|
@ -664,23 +663,6 @@ defineExpose({
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<ParameterInputFull
|
|
||||||
:parameter="{
|
|
||||||
name: 'ignoreTypeMismatchErrors',
|
|
||||||
type: 'boolean',
|
|
||||||
displayName: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.displayName'),
|
|
||||||
default: false,
|
|
||||||
description: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.description'),
|
|
||||||
}"
|
|
||||||
:path="props.path + '.ignoreTypeMismatchErrors'"
|
|
||||||
:value="state.paramValue.ignoreTypeMismatchErrors"
|
|
||||||
@update="
|
|
||||||
(x: IUpdateInformation<NodeParameterValueType>) => {
|
|
||||||
state.paramValue.ignoreTypeMismatchErrors = x.value as boolean;
|
|
||||||
emitValueChanged();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -282,7 +282,7 @@ describe('RunData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
|
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution');
|
||||||
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||||
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||||
|
|
||||||
|
@ -311,7 +311,7 @@ describe('RunData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Parent Execution 123');
|
expect(getByTestId('related-execution-link')).toHaveTextContent('View parent execution');
|
||||||
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||||
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||||
|
|
||||||
|
@ -344,7 +344,7 @@ describe('RunData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
|
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution 123');
|
||||||
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
|
||||||
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
|
||||||
|
|
||||||
|
@ -389,7 +389,7 @@ describe('RunData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
expect(getByTestId('related-execution-link')).toBeInTheDocument();
|
||||||
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
|
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution 123');
|
||||||
|
|
||||||
expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument();
|
expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument();
|
||||||
expect(getByTestId('run-selector')).toBeInTheDocument();
|
expect(getByTestId('run-selector')).toBeInTheDocument();
|
||||||
|
|
|
@ -1263,9 +1263,13 @@ function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.subExecution) {
|
if (task.subExecution) {
|
||||||
return i18n.baseText('runData.openSubExecution', {
|
if (activeTaskMetadata.value?.subExecutionsCount === 1) {
|
||||||
interpolate: { id: task.subExecution.executionId },
|
return i18n.baseText('runData.openSubExecutionSingle');
|
||||||
});
|
} else {
|
||||||
|
return i18n.baseText('runData.openSubExecutionWithId', {
|
||||||
|
interpolate: { id: task.subExecution.executionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -149,7 +149,7 @@ const outputError = computed(() => {
|
||||||
>
|
>
|
||||||
<N8nIcon icon="external-link-alt" size="xsmall" />
|
<N8nIcon icon="external-link-alt" size="xsmall" />
|
||||||
{{
|
{{
|
||||||
i18n.baseText('runData.openSubExecution', {
|
i18n.baseText('runData.openSubExecutionWithId', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
id: runMeta.subExecution?.executionId,
|
id: runMeta.subExecution?.executionId,
|
||||||
},
|
},
|
||||||
|
|
|
@ -439,7 +439,7 @@ watch(focusedMappableInput, (curr) => {
|
||||||
>
|
>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
:content="
|
:content="
|
||||||
i18n.baseText('runData.table.inspectSubExecution', {
|
i18n.baseText('runData.table.viewSubExecution', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
|
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
|
||||||
},
|
},
|
||||||
|
@ -575,7 +575,7 @@ watch(focusedMappableInput, (curr) => {
|
||||||
>
|
>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
:content="
|
:content="
|
||||||
i18n.baseText('runData.table.inspectSubExecution', {
|
i18n.baseText('runData.table.viewSubExecution', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
|
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
|
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
|
||||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
|
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||||
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import {
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { ifNotIn } from '@codemirror/autocomplete';
|
import { ifNotIn } from '@codemirror/autocomplete';
|
||||||
import { history, toggleComment } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
||||||
import { Prec, type Line } from '@codemirror/state';
|
import { Prec, type Line } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
|
@ -34,10 +30,9 @@ import {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
keywordCompletionSource,
|
keywordCompletionSource,
|
||||||
} from '@n8n/codemirror-lang-sql';
|
} from '@n8n/codemirror-lang-sql';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
|
||||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
|
import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
|
|
||||||
const SQL_DIALECTS = {
|
const SQL_DIALECTS = {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
|
@ -87,7 +82,7 @@ const extensions = computed(() => {
|
||||||
const baseExtensions = [
|
const baseExtensions = [
|
||||||
sqlWithN8nLanguageSupport(),
|
sqlWithN8nLanguageSupport(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
codeNodeEditorTheme({
|
codeEditorTheme({
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: props.fullscreen ? '100%' : '40vh',
|
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||||
minHeight: '10vh',
|
minHeight: '10vh',
|
||||||
|
@ -100,15 +95,7 @@ const extensions = computed(() => {
|
||||||
if (!props.isReadOnly) {
|
if (!props.isReadOnly) {
|
||||||
return baseExtensions.concat([
|
return baseExtensions.concat([
|
||||||
history(),
|
history(),
|
||||||
Prec.highest(
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
keymap.of([
|
|
||||||
...tabKeyMap(),
|
|
||||||
...enterKeyMap,
|
|
||||||
...historyKeyMap,
|
|
||||||
...autocompleteKeyMap,
|
|
||||||
{ key: 'Mod-/', run: toggleComment },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
|
@ -185,10 +172,10 @@ function line(lineNumber: number): Line | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightLine(lineNumber: number | 'final') {
|
function highlightLine(lineNumber: number | 'last') {
|
||||||
if (!editor.value) return;
|
if (!editor.value) return;
|
||||||
|
|
||||||
if (lineNumber === 'final') {
|
if (lineNumber === 'last') {
|
||||||
editor.value.dispatch({
|
editor.value.dispatch({
|
||||||
selection: { anchor: editor.value.state.doc.length },
|
selection: { anchor: editor.value.state.doc.length },
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,7 +16,10 @@ interface TagsDropdownProps {
|
||||||
allTags: ITag[];
|
allTags: ITag[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
tagsById: Record<string, ITag>;
|
tagsById: Record<string, ITag>;
|
||||||
|
createEnabled?: boolean;
|
||||||
|
manageEnabled?: boolean;
|
||||||
createTag?: (name: string) => Promise<ITag>;
|
createTag?: (name: string) => Promise<ITag>;
|
||||||
|
multipleLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -27,6 +30,10 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
eventBus: null,
|
eventBus: null,
|
||||||
|
createEnabled: true,
|
||||||
|
manageEnabled: true,
|
||||||
|
createTag: undefined,
|
||||||
|
multipleLimit: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -59,6 +66,17 @@ const appliedTags = computed<string[]>(() => {
|
||||||
return props.modelValue.filter((id: string) => props.tagsById[id]);
|
return props.modelValue.filter((id: string) => props.tagsById[id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const containerClasses = computed(() => {
|
||||||
|
return { 'tags-container': true, focused: focused.value };
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropdownClasses = computed(() => ({
|
||||||
|
'tags-dropdown': true,
|
||||||
|
[`tags-dropdown-${dropdownId}`]: true,
|
||||||
|
'tags-dropdown-create-enabled': props.createEnabled,
|
||||||
|
'tags-dropdown-manage-enabled': props.manageEnabled,
|
||||||
|
}));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.allTags,
|
() => props.allTags,
|
||||||
() => {
|
() => {
|
||||||
|
@ -189,7 +207,7 @@ onClickOutside(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
|
<div ref="container" :class="containerClasses" @keydown.stop>
|
||||||
<N8nSelect
|
<N8nSelect
|
||||||
ref="selectRef"
|
ref="selectRef"
|
||||||
:teleported="true"
|
:teleported="true"
|
||||||
|
@ -199,16 +217,17 @@ onClickOutside(
|
||||||
:filter-method="filterOptions"
|
:filter-method="filterOptions"
|
||||||
filterable
|
filterable
|
||||||
multiple
|
multiple
|
||||||
|
:multiple-limit="props.multipleLimit"
|
||||||
:reserve-keyword="false"
|
:reserve-keyword="false"
|
||||||
loading-text="..."
|
loading-text="..."
|
||||||
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')"
|
:popper-class="dropdownClasses"
|
||||||
data-test-id="tags-dropdown"
|
data-test-id="tags-dropdown"
|
||||||
@update:model-value="onTagsUpdated"
|
@update:model-value="onTagsUpdated"
|
||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
@remove-tag="onRemoveTag"
|
@remove-tag="onRemoveTag"
|
||||||
>
|
>
|
||||||
<N8nOption
|
<N8nOption
|
||||||
v-if="options.length === 0 && filter"
|
v-if="createEnabled && options.length === 0 && filter"
|
||||||
:key="CREATE_KEY"
|
:key="CREATE_KEY"
|
||||||
ref="createRef"
|
ref="createRef"
|
||||||
:value="CREATE_KEY"
|
:value="CREATE_KEY"
|
||||||
|
@ -220,7 +239,7 @@ onClickOutside(
|
||||||
</span>
|
</span>
|
||||||
</N8nOption>
|
</N8nOption>
|
||||||
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
||||||
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
||||||
<span v-if="allTags.length > 0">{{
|
<span v-if="allTags.length > 0">{{
|
||||||
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
||||||
}}</span>
|
}}</span>
|
||||||
|
@ -237,7 +256,7 @@ onClickOutside(
|
||||||
data-test-id="tag"
|
data-test-id="tag"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
|
<N8nOption v-if="manageEnabled" :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
|
||||||
<font-awesome-icon icon="cog" />
|
<font-awesome-icon icon="cog" />
|
||||||
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
|
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
|
||||||
</N8nOption>
|
</N8nOption>
|
||||||
|
@ -313,7 +332,7 @@ onClickOutside(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
.tags-dropdown-manage-enabled &:after {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
display: block;
|
display: block;
|
||||||
min-height: $--item-height;
|
min-height: $--item-height;
|
||||||
|
|
|
@ -1,18 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { EditableField } from '../types';
|
||||||
|
|
||||||
export interface EvaluationHeaderProps {
|
export interface EvaluationHeaderProps {
|
||||||
modelValue: {
|
modelValue: EditableField<string>;
|
||||||
value: string;
|
startEditing: (field: 'name') => void;
|
||||||
isEditing: boolean;
|
saveChanges: (field: 'name') => void;
|
||||||
tempValue: string;
|
handleKeydown: (e: KeyboardEvent, field: 'name') => void;
|
||||||
};
|
|
||||||
startEditing: (field: string) => void;
|
|
||||||
saveChanges: (field: string) => void;
|
|
||||||
handleKeydown: (e: KeyboardEvent, field: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
|
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
|
||||||
defineProps<EvaluationHeaderProps>();
|
defineProps<EvaluationHeaderProps>();
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
|
|
@ -2,12 +2,14 @@
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { ElCollapseTransition } from 'element-plus';
|
import { ElCollapseTransition } from 'element-plus';
|
||||||
import { ref, nextTick } from 'vue';
|
import { ref, nextTick } from 'vue';
|
||||||
|
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
|
||||||
|
|
||||||
interface EvaluationStep {
|
interface EvaluationStep {
|
||||||
title: string;
|
title: string;
|
||||||
warning?: boolean;
|
warning?: boolean;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
tooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<EvaluationStep>(), {
|
const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||||
|
@ -15,12 +17,14 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||||
warning: false,
|
warning: false,
|
||||||
small: false,
|
small: false,
|
||||||
expanded: true,
|
expanded: true,
|
||||||
|
tooltip: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const isExpanded = ref(props.expanded);
|
const isExpanded = ref(props.expanded);
|
||||||
const contentRef = ref<HTMLElement | null>(null);
|
const contentRef = ref<HTMLElement | null>(null);
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const isTooltipVisible = ref(false);
|
||||||
|
|
||||||
const toggleExpand = async () => {
|
const toggleExpand = async () => {
|
||||||
isExpanded.value = !isExpanded.value;
|
isExpanded.value = !isExpanded.value;
|
||||||
|
@ -31,11 +35,32 @@ const toggleExpand = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
isTooltipVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
isTooltipVisible.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]">
|
<div
|
||||||
<div :class="$style.content">
|
ref="containerRef"
|
||||||
|
:class="[$style.evaluationStep, small && $style.small]"
|
||||||
|
data-test-id="evaluation-step"
|
||||||
|
>
|
||||||
|
<N8nTooltip :disabled="!tooltip" placement="right" :offset="25" :visible="isTooltipVisible">
|
||||||
|
<template #content>
|
||||||
|
{{ tooltip }}
|
||||||
|
</template>
|
||||||
|
<!-- This empty div is needed to ensure the tooltip trigger area spans the full width of the step.
|
||||||
|
Without it, the tooltip would only show when hovering over the content div, which is narrower.
|
||||||
|
The contentPlaceholder creates an invisible full-width area that can trigger the tooltip. -->
|
||||||
|
<div :class="$style.contentPlaceholder"></div>
|
||||||
|
</N8nTooltip>
|
||||||
|
<div :class="$style.content" @mouseenter="showTooltip" @mouseleave="hideTooltip">
|
||||||
<div :class="$style.header">
|
<div :class="$style.header">
|
||||||
<div :class="[$style.icon, warning && $style.warning]">
|
<div :class="[$style.icon, warning && $style.warning]">
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
|
@ -47,6 +72,7 @@ const toggleExpand = async () => {
|
||||||
:class="$style.collapseButton"
|
:class="$style.collapseButton"
|
||||||
:aria-expanded="isExpanded"
|
:aria-expanded="isExpanded"
|
||||||
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
|
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
|
||||||
|
data-test-id="evaluation-step-collapse-button"
|
||||||
@click="toggleExpand"
|
@click="toggleExpand"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
@ -59,7 +85,7 @@ const toggleExpand = async () => {
|
||||||
</div>
|
</div>
|
||||||
<ElCollapseTransition v-if="$slots.cardContent">
|
<ElCollapseTransition v-if="$slots.cardContent">
|
||||||
<div v-show="isExpanded" :class="$style.cardContentWrapper">
|
<div v-show="isExpanded" :class="$style.cardContentWrapper">
|
||||||
<div ref="contentRef" :class="$style.cardContent">
|
<div ref="contentRef" :class="$style.cardContent" data-test-id="evaluation-step-content">
|
||||||
<slot name="cardContent" />
|
<slot name="cardContent" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -85,6 +111,14 @@ const toggleExpand = async () => {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contentPlaceholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
.icon {
|
.icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -1,22 +1,30 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { TestMetricRecord } from '@/api/testDefinition.ee';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
export interface MetricsInputProps {
|
export interface MetricsInputProps {
|
||||||
modelValue: string[];
|
modelValue: Array<Partial<TestMetricRecord>>;
|
||||||
}
|
}
|
||||||
const props = defineProps<MetricsInputProps>();
|
const props = defineProps<MetricsInputProps>();
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: MetricsInputProps['modelValue']];
|
||||||
|
deleteMetric: [metric: Partial<TestMetricRecord>];
|
||||||
|
}>();
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
|
||||||
function addNewMetric() {
|
function addNewMetric() {
|
||||||
emit('update:modelValue', [...props.modelValue, '']);
|
emit('update:modelValue', [...props.modelValue, { name: '' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMetric(index: number, value: string) {
|
function updateMetric(index: number, name: string) {
|
||||||
const newMetrics = [...props.modelValue];
|
const newMetrics = [...props.modelValue];
|
||||||
newMetrics[index] = value;
|
newMetrics[index].name = name;
|
||||||
emit('update:modelValue', newMetrics);
|
emit('update:modelValue', newMetrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
|
||||||
|
emit('deleteMetric', metric);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -27,14 +35,15 @@ function updateMetric(index: number, value: string) {
|
||||||
:class="$style.metricField"
|
:class="$style.metricField"
|
||||||
>
|
>
|
||||||
<div :class="$style.metricsContainer">
|
<div :class="$style.metricsContainer">
|
||||||
<div v-for="(metric, index) in modelValue" :key="index">
|
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
|
||||||
<N8nInput
|
<N8nInput
|
||||||
:ref="`metric_${index}`"
|
:ref="`metric_${index}`"
|
||||||
data-test-id="evaluation-metric-item"
|
data-test-id="evaluation-metric-item"
|
||||||
:model-value="metric"
|
:model-value="metric.name"
|
||||||
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
|
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
|
||||||
@update:model-value="(value: string) => updateMetric(index, value)"
|
@update:model-value="(value: string) => updateMetric(index, value)"
|
||||||
/>
|
/>
|
||||||
|
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric)" />
|
||||||
</div>
|
</div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
|
@ -54,6 +63,11 @@ function updateMetric(index: number, value: string) {
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metricItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.metricField {
|
.metricField {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: var(--spacing-xs);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue