mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
Merge remote-tracking branch 'origin/master' into ADO-3305
This commit is contained in:
commit
8b147f905d
1
.github/workflows/ci-postgres-mysql.yml
vendored
1
.github/workflows/ci-postgres-mysql.yml
vendored
|
|
@ -7,6 +7,7 @@ on:
|
|||
pull_request:
|
||||
paths:
|
||||
- packages/cli/src/databases/**
|
||||
- packages/cli/src/modules/*/database/**
|
||||
- .github/workflows/ci-postgres-mysql.yml
|
||||
- .github/docker-compose.yml
|
||||
pull_request_review:
|
||||
|
|
|
|||
37
.github/workflows/e2e-reusable.yml
vendored
37
.github/workflows/e2e-reusable.yml
vendored
|
|
@ -65,11 +65,9 @@ jobs:
|
|||
run: echo "value=sha-$GITHUB_SHA-time-$(date +"%s")" >> $GITHUB_OUTPUT
|
||||
|
||||
install:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: ['prepare']
|
||||
container:
|
||||
image: cypress/${{ inputs.run-env }}
|
||||
options: --user 1001
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
|
|
@ -83,10 +81,22 @@ jobs:
|
|||
|
||||
- uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
|
||||
|
||||
- name: Cache build artifacts
|
||||
id: cache-build-artifacts
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
/home/runner/.cache/Cypress
|
||||
/github/home/.pnpm-store
|
||||
./packages/**/dist
|
||||
key: ${{ github.sha }}:build-artifacts
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-build-artifacts.outputs.cache-hit != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cypress build
|
||||
if: steps.cache-build-artifacts.outputs.cache-hit != 'true'
|
||||
uses: cypress-io/github-action@1b70233146622b69e789ccdd4f9452adc638d25a # v6.6.1
|
||||
with:
|
||||
# Disable running of tests within install job
|
||||
|
|
@ -95,23 +105,13 @@ jobs:
|
|||
build: pnpm build
|
||||
|
||||
- name: Cypress install
|
||||
if: steps.cache-build-artifacts.outputs.cache-hit != 'true'
|
||||
working-directory: cypress
|
||||
run: pnpm cypress:install
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
/github/home/.cache
|
||||
/github/home/.pnpm-store
|
||||
./packages/**/dist
|
||||
key: ${{ github.sha }}-e2e
|
||||
|
||||
testing:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: cypress/${{ inputs.run-env }}
|
||||
options: --user 1001
|
||||
needs: ['prepare', 'install']
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -133,13 +133,14 @@ jobs:
|
|||
- uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
|
||||
|
||||
- name: Restore cached pnpm modules
|
||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: cache-build-artifacts
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
/github/home/.cache
|
||||
/home/runner/.cache/Cypress
|
||||
/github/home/.pnpm-store
|
||||
./packages/**/dist
|
||||
key: ${{ github.sha }}-e2e
|
||||
key: ${{ github.sha }}:build-artifacts
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
|
|
|||
4
.github/workflows/linting-reusable.yml
vendored
4
.github/workflows/linting-reusable.yml
vendored
|
|
@ -17,9 +17,7 @@ on:
|
|||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
|
|
|
|||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -1,3 +1,47 @@
|
|||
# [1.85.0](https://github.com/n8n-io/n8n/compare/n8n@1.84.0...n8n@1.85.0) (2025-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Allow saved credenitals types of up to 64 characters instead of 32 ([#13985](https://github.com/n8n-io/n8n/issues/13985)) ([bc15bb1](https://github.com/n8n-io/n8n/commit/bc15bb18d9f33abdeed24e26826e7f3308d3eef2))
|
||||
* Allow username to be set in Redis chat memory ([#13926](https://github.com/n8n-io/n8n/issues/13926)) ([b2e359a](https://github.com/n8n-io/n8n/commit/b2e359ac1c2dfdf79f8d50fe83998eda5fc34dd2))
|
||||
* **core:** Allow running webhook servers in multi-main mode ([#13989](https://github.com/n8n-io/n8n/issues/13989)) ([e0fd505](https://github.com/n8n-io/n8n/commit/e0fd50554d48c873c8f77169d1a17438391dd973))
|
||||
* **core:** Bring back the missing GMT and UTC timezone for workflow settings ([#13999](https://github.com/n8n-io/n8n/issues/13999)) ([bda0688](https://github.com/n8n-io/n8n/commit/bda068880ea7a44718e01a156e97f09c9ec2bc46))
|
||||
* **core:** Do not use `url.includes` to check for domain names ([#13802](https://github.com/n8n-io/n8n/issues/13802)) ([d3bc80c](https://github.com/n8n-io/n8n/commit/d3bc80c22bbbf0ae39c88a6f085d5f80aa8a0e82))
|
||||
* **core:** Don't fail partial execution when an unrelated node is dirty ([#13925](https://github.com/n8n-io/n8n/issues/13925)) ([918cc51](https://github.com/n8n-io/n8n/commit/918cc51abc79bbcfb6a333d5ecafa07a9e986b6f))
|
||||
* **core:** Ensure frontend sentry releases also follow semver ([#14019](https://github.com/n8n-io/n8n/issues/14019)) ([401ed2c](https://github.com/n8n-io/n8n/commit/401ed2ce1194ad7ff238debff418f0db77eb06e6))
|
||||
* **editor:** Add "time saved per execution" workflow setting ([#13369](https://github.com/n8n-io/n8n/issues/13369)) ([6992c36](https://github.com/n8n-io/n8n/commit/6992c36ebb3aa608ce31396f9b7ed0aa10c80299))
|
||||
* **editor:** Add smart decimals directive ([#14054](https://github.com/n8n-io/n8n/issues/14054)) ([1a26fc2](https://github.com/n8n-io/n8n/commit/1a26fc2762dee366d2ce7ccf24e173cdc761c70c))
|
||||
* **editor:** Fix routing between workflow editing and new workflow pages ([#14031](https://github.com/n8n-io/n8n/issues/14031)) ([6817abe](https://github.com/n8n-io/n8n/commit/6817abe47facd7ff0e42a66599827d42c4df757c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add appendN8nAttribution option to sendAndWait operation ([#13697](https://github.com/n8n-io/n8n/issues/13697)) ([d6d5a66](https://github.com/n8n-io/n8n/commit/d6d5a66f5dc28d926755ca8153f91c7be0742cf5))
|
||||
* Add xAiGrok Chat Model node and credentials ([#13670](https://github.com/n8n-io/n8n/issues/13670)) ([cc502fb](https://github.com/n8n-io/n8n/commit/cc502fb8c34b65d569b4abe4603cc8ef1eadc7a7))
|
||||
* Allow custom scopes for Entra credential ([#13796](https://github.com/n8n-io/n8n/issues/13796)) ([7e10361](https://github.com/n8n-io/n8n/commit/7e1036187ff7bd5be990f191a3ac8ef002e7812a))
|
||||
* **API:** Fix generation strategy for mysql/mariadb ([#14028](https://github.com/n8n-io/n8n/issues/14028)) ([24d8eac](https://github.com/n8n-io/n8n/commit/24d8eac85d8ce95671aabf8500139b3ef3e19a56))
|
||||
* **API:** Implement compaction logic for insights ([#14062](https://github.com/n8n-io/n8n/issues/14062)) ([d8433d2](https://github.com/n8n-io/n8n/commit/d8433d289543c40854e59b0384be356a3d7b947d))
|
||||
* Cat 720 improve pre merge ci ([#14116](https://github.com/n8n-io/n8n/issues/14116)) ([743b63e](https://github.com/n8n-io/n8n/commit/743b63e97a9a96dfaf35f138a79eddaad9bb2dbb))
|
||||
* **core:** Add folder synchronization to environments feature ([#14005](https://github.com/n8n-io/n8n/issues/14005)) ([198f17d](https://github.com/n8n-io/n8n/commit/198f17dbcf0b21e579f9a68466494662257dbe44))
|
||||
* **core:** Add tool to uninstall a community node ([#14026](https://github.com/n8n-io/n8n/issues/14026)) ([e0f9506](https://github.com/n8n-io/n8n/commit/e0f9506912aa6a129df332185063291f0627f9ca))
|
||||
* **core:** Allow community nodes to be used as tools ([#14042](https://github.com/n8n-io/n8n/issues/14042)) ([9d698ed](https://github.com/n8n-io/n8n/commit/9d698edcebc8cdbf9fefc3bf89a13f9daa32f40b))
|
||||
* **core:** Allow customizing auth cookie samesite attribute and CSP headers ([#13855](https://github.com/n8n-io/n8n/issues/13855)) ([17fc5c1](https://github.com/n8n-io/n8n/commit/17fc5c148b99b8f346abf2142a1d2bee567b2621))
|
||||
* **core:** Enable folders feature via license server ([#13942](https://github.com/n8n-io/n8n/issues/13942)) ([fa7e7ac](https://github.com/n8n-io/n8n/commit/fa7e7ac2e7b38418619ebe1f3839d47c491419d2))
|
||||
* **core:** Implement API to retrieve summary metrics ([#13927](https://github.com/n8n-io/n8n/issues/13927)) ([b616ceb](https://github.com/n8n-io/n8n/commit/b616ceb08b712ecd350114acc48a9a0f35843c0a))
|
||||
* **core:** Support importing a singular workflow object ([#14041](https://github.com/n8n-io/n8n/issues/14041)) ([91b2796](https://github.com/n8n-io/n8n/commit/91b27964d80309ce493200289b31a83ef6051b4d))
|
||||
* **core:** Update endpoint to update a workflow, to support updating the workflow parent folder (no-chagelog) ([#13906](https://github.com/n8n-io/n8n/issues/13906)) ([3a5cc4a](https://github.com/n8n-io/n8n/commit/3a5cc4ae957ea5f370472f08d2af4ac29c3b21b2))
|
||||
* **editor:** Add variables and context section to schema view ([#13875](https://github.com/n8n-io/n8n/issues/13875)) ([c06ce76](https://github.com/n8n-io/n8n/commit/c06ce765f11dcde4731d3739e1aa5f27351c3cc2))
|
||||
* **editor:** Always show collapsed panel at the bottom of canvas ([#13715](https://github.com/n8n-io/n8n/issues/13715)) ([2e9d3ad](https://github.com/n8n-io/n8n/commit/2e9d3ad3e14da7aa2f3b3b9577858791e9128908))
|
||||
* **editor:** Insights summary banner ([#13424](https://github.com/n8n-io/n8n/issues/13424)) ([df474f3](https://github.com/n8n-io/n8n/commit/df474f3ccbc629a8e308359e6a4973cc00b86e17))
|
||||
* **Extract from File Node:** Add relax_quote option ([#13607](https://github.com/n8n-io/n8n/issues/13607)) ([830d2c5](https://github.com/n8n-io/n8n/commit/830d2c5df53c5436f89868dfe23cf55c41585a46))
|
||||
* **n8n Form Trigger Node:** Respond with File ([#13507](https://github.com/n8n-io/n8n/issues/13507)) ([8f46371](https://github.com/n8n-io/n8n/commit/8f46371d77262aa0a924e1c58cf9691327e0f193))
|
||||
* **Salesforce Node:** Add support for PKCE ([#14082](https://github.com/n8n-io/n8n/issues/14082)) ([defeb2e](https://github.com/n8n-io/n8n/commit/defeb2e817dbc559844124f20e6bebf7717d878a))
|
||||
* **SeaTable Node:** Update node with new options ([#11431](https://github.com/n8n-io/n8n/issues/11431)) ([d0fdb11](https://github.com/n8n-io/n8n/commit/d0fdb11499de2e5fb1602b7cc86f2b24543ce50f))
|
||||
* **Simple Vector Store Node:** Implement store cleaning based on age/used memory ([#13986](https://github.com/n8n-io/n8n/issues/13986)) ([e06c552](https://github.com/n8n-io/n8n/commit/e06c552a6a0471ec60862247f6a597b8ab5f9cd3))
|
||||
|
||||
|
||||
|
||||
# [1.84.0](https://github.com/n8n-io/n8n/compare/n8n@1.83.0...n8n@1.84.0) (2025-03-17)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -212,6 +212,11 @@ export function getNewFolderNameInput() {
|
|||
export function getNewFolderModalErrorMessage() {
|
||||
return cy.get('.el-message-box__errormsg').filter(':visible');
|
||||
}
|
||||
|
||||
export function getProjectTab(tabId: string) {
|
||||
return cy.getByTestId('project-tabs').find(`#${tabId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
export function getSaveChangesModal() {
|
||||
return cy.get('.el-overlay').contains('Save changes before leaving?');
|
||||
}
|
||||
|
||||
// this is the button next to 'Save Changes'
|
||||
export function getCancelSaveChangesButton() {
|
||||
return cy.get('.btn--cancel');
|
||||
}
|
||||
|
||||
// This is the top right 'x'
|
||||
export function getCloseSaveChangesButton() {
|
||||
return cy.get('.el-message-box__headerbtn');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ export function getWorkflowsPageUrl() {
|
|||
return '/home/workflows';
|
||||
}
|
||||
|
||||
export const getCreateWorkflowButton = () => cy.getByTestId('add-resource-workflow');
|
||||
|
||||
export const getNewWorkflowCardButton = () => cy.getByTestId('new-workflow-card');
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,26 +1,59 @@
|
|||
import { getSaveChangesModal } from '../composables/modals/save-changes-modal';
|
||||
import {
|
||||
getCancelSaveChangesButton,
|
||||
getCloseSaveChangesButton,
|
||||
getSaveChangesModal,
|
||||
} from '../composables/modals/save-changes-modal';
|
||||
import { getHomeButton } from '../composables/projects';
|
||||
import { addNodeToCanvas } from '../composables/workflow';
|
||||
import {
|
||||
getCreateWorkflowButton,
|
||||
getNewWorkflowCardButton,
|
||||
getWorkflowsPageUrl,
|
||||
visitWorkflowsPage,
|
||||
} from '../composables/workflowsPage';
|
||||
import { EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
|
||||
|
||||
const WorkflowsPage = new WorkflowsPageClass();
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
|
||||
describe('Workflows', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(WorkflowsPage.url);
|
||||
visitWorkflowsPage();
|
||||
});
|
||||
|
||||
it('should ask to save unsaved changes before leaving route', () => {
|
||||
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
|
||||
WorkflowsPage.getters.newWorkflowButtonCard().click();
|
||||
getNewWorkflowCardButton().should('be.visible');
|
||||
getNewWorkflowCardButton().click();
|
||||
|
||||
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
|
||||
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
|
||||
cy.getByTestId('project-home-menu-item').click();
|
||||
getHomeButton().click();
|
||||
|
||||
// We expect to still be on the workflow route here
|
||||
cy.url().should('include', '/workflow/');
|
||||
|
||||
getSaveChangesModal().should('be.visible');
|
||||
getCancelSaveChangesButton().click();
|
||||
|
||||
// Only now do we switch
|
||||
cy.url().should('include', getWorkflowsPageUrl());
|
||||
});
|
||||
|
||||
it('should correct route after cancelling saveChangesModal', () => {
|
||||
getCreateWorkflowButton().click();
|
||||
|
||||
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
|
||||
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
|
||||
// Here we go back via browser rather than the home button
|
||||
// As this already updates the route
|
||||
cy.go(-1);
|
||||
|
||||
cy.url().should('include', getWorkflowsPageUrl());
|
||||
|
||||
getSaveChangesModal().should('be.visible');
|
||||
getCloseSaveChangesButton().click();
|
||||
|
||||
// Confirm the url is back to the workflow
|
||||
cy.url().should('include', '/workflow/');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
getPersonalProjectMenuItem,
|
||||
getProjectEmptyState,
|
||||
getProjectMenuItem,
|
||||
getProjectTab,
|
||||
getVisibleListBreadcrumbs,
|
||||
getWorkflowCard,
|
||||
getWorkflowCardBreadcrumbs,
|
||||
|
|
@ -64,12 +65,16 @@ describe('Folders', () => {
|
|||
|
||||
describe('Create and navigate folders', () => {
|
||||
it('should create folder from the project header', () => {
|
||||
// 1. In project root
|
||||
getPersonalProjectMenuItem().click();
|
||||
createFolderFromProjectHeader('My Folder');
|
||||
getFolderCards().should('have.length.greaterThan', 0);
|
||||
// Clicking on the success toast should navigate to the folder
|
||||
successToast().find('a').click();
|
||||
getCurrentBreadcrumb().should('contain.text', 'My Folder');
|
||||
// 2. In a folder
|
||||
createFolderFromListHeaderButton('My Folder 2');
|
||||
getFolderCard('My Folder 2').should('exist');
|
||||
});
|
||||
|
||||
it('should not allow illegal folder names', () => {
|
||||
|
|
@ -217,6 +222,10 @@ describe('Folders', () => {
|
|||
cy.getByTestId('action-folder').should('exist');
|
||||
createFolderFromProjectHeader('Personal Folder');
|
||||
getFolderCards().should('exist');
|
||||
// Create folder option should not be available on credentials tab
|
||||
getProjectTab('ProjectsCredentials').click();
|
||||
getAddResourceDropdown().click();
|
||||
cy.getByTestId('action-folder').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { setCredentialValues } from '../composables/modals/credential-modal';
|
||||
import { clickCreateNewCredential, setParameterSelectByContent } from '../composables/ndv';
|
||||
import {
|
||||
clickCreateNewCredential,
|
||||
clickGetBackToCanvas,
|
||||
setParameterSelectByContent,
|
||||
} from '../composables/ndv';
|
||||
import { openNode } from '../composables/workflow';
|
||||
import {
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
|
||||
|
|
@ -617,6 +622,42 @@ describe('NDV', () => {
|
|||
// Sinse code tool require alphanumeric tool name it would also show an error(2 errors, 1 for each tool node)
|
||||
cy.get('[class*=hasIssues]').should('have.length', 3);
|
||||
});
|
||||
|
||||
it('should have the floating nodes in correct order', () => {
|
||||
cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes');
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => {},
|
||||
() => {
|
||||
// Needed in V2 as all nodes remain selected when clicking on a selected node
|
||||
workflowPage.actions.deselectAll();
|
||||
},
|
||||
);
|
||||
|
||||
// The first merge node has the wires crossed, so `Edit Fields1` is first in the order of connected nodes
|
||||
openNode('Merge');
|
||||
getFloatingNodeByPosition('inputMain').should('exist');
|
||||
getFloatingNodeByPosition('inputMain').should('have.length', 2);
|
||||
getFloatingNodeByPosition('inputMain')
|
||||
.first()
|
||||
.should('have.attr', 'data-node-name', 'Edit Fields1');
|
||||
getFloatingNodeByPosition('inputMain')
|
||||
.last()
|
||||
.should('have.attr', 'data-node-name', 'Edit Fields0');
|
||||
|
||||
clickGetBackToCanvas();
|
||||
|
||||
// The second merge node does not have wires crossed, so `Edit Fields0` is first
|
||||
openNode('Merge1');
|
||||
getFloatingNodeByPosition('inputMain').should('exist');
|
||||
getFloatingNodeByPosition('inputMain').should('have.length', 2);
|
||||
getFloatingNodeByPosition('inputMain')
|
||||
.first()
|
||||
.should('have.attr', 'data-node-name', 'Edit Fields0');
|
||||
getFloatingNodeByPosition('inputMain')
|
||||
.last()
|
||||
.should('have.attr', 'data-node-name', 'Edit Fields1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show node name and version in settings', () => {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,54 @@
|
|||
1600,
|
||||
740
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
440,
|
||||
-140
|
||||
],
|
||||
"id": "a00959d3-8d4b-40af-b4f2-35ca3d73fd84",
|
||||
"name": "Merge"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
-20,
|
||||
-120
|
||||
],
|
||||
"id": "a5cbc221-ccfd-4034-a648-6a192834af81",
|
||||
"name": "Edit Fields0"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
0,
|
||||
100
|
||||
],
|
||||
"id": "d3b4c17a-bee8-418b-a721-5debafd1ce11",
|
||||
"name": "Edit Fields1"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.merge",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
440,
|
||||
100
|
||||
],
|
||||
"id": "b23a2a43-ffac-41a5-a265-054e21a57d70",
|
||||
"name": "Merge1"
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
|
|
@ -161,7 +209,40 @@
|
|||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Edit Fields0": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Edit Fields1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Merge",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Merge1",
|
||||
"type": "main",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { compilerOptions } = require('./tsconfig.json');
|
||||
const { pathsToModuleNameMapper } = require('ts-jest')
|
||||
const { compilerOptions } = require('get-tsconfig').getTsconfig().config;
|
||||
|
||||
/** @type {import('ts-jest').TsJestGlobalOptions} */
|
||||
const tsJestOptions = {
|
||||
|
|
@ -10,7 +11,6 @@ const tsJestOptions = {
|
|||
},
|
||||
};
|
||||
|
||||
const { baseUrl, paths } = require('get-tsconfig').getTsconfig().config?.compilerOptions;
|
||||
|
||||
const isCoverageEnabled = process.env.COVERAGE_ENABLED === 'true';
|
||||
|
||||
|
|
@ -24,15 +24,7 @@ const config = {
|
|||
'^.+\\.ts$': ['ts-jest', tsJestOptions],
|
||||
},
|
||||
// This resolve the path mappings from the tsconfig relative to each jest.config.js
|
||||
moduleNameMapper: Object.entries(paths || {}).reduce((acc, [path, [mapping]]) => {
|
||||
path = `^${path.replace(/\/\*$/, '/(.*)$')}`;
|
||||
mapping = mapping.replace(/^\.?\.\/(?:(.*)\/)?\*$/, '$1');
|
||||
mapping = mapping ? `/${mapping}` : '';
|
||||
acc[path] = mapping.startsWith('/test')
|
||||
? '<rootDir>' + mapping + '/$1'
|
||||
: '<rootDir>' + (baseUrl ? `/${baseUrl.replace(/^\.\//, '')}` : '') + mapping + '/$1';
|
||||
return acc;
|
||||
}, {}),
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: `<rootDir>${compilerOptions.baseUrl ? `/${compilerOptions.baseUrl.replace(/^\.\//, '')}` : ''}` }),
|
||||
setupFilesAfterEnv: ['jest-expect-message'],
|
||||
collectCoverage: isCoverageEnabled,
|
||||
coverageReporters: ['text-summary', 'lcov', 'html-spa'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -59,3 +59,5 @@ export { CreateFolderDto } from './folders/create-folder.dto';
|
|||
export { UpdateFolderDto } from './folders/update-folder.dto';
|
||||
export { DeleteFolderDto } from './folders/delete-folder.dto';
|
||||
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
|
||||
|
||||
export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { ListInsightsWorkflowQueryDto } from '../list-workflow-query.dto';
|
||||
|
||||
const DEFAULT_PAGINATION = { skip: 0, take: 10 };
|
||||
|
||||
describe('ListInsightsWorkflowQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty object (no filters)',
|
||||
request: {},
|
||||
parsedResult: DEFAULT_PAGINATION,
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy',
|
||||
request: {
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid skip and take',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: '20',
|
||||
},
|
||||
parsedResult: {
|
||||
skip: 0,
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'full query parameters',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: '10',
|
||||
sortBy: 'total:desc',
|
||||
},
|
||||
parsedResult: {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
sortBy: 'total:desc',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request, parsedResult }) => {
|
||||
const result = ListInsightsWorkflowQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
if (parsedResult) {
|
||||
expect(result.data).toMatchObject(parsedResult);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid skip format',
|
||||
request: {
|
||||
skip: 'not-a-number',
|
||||
take: '10',
|
||||
},
|
||||
expectedErrorPath: ['skip'],
|
||||
},
|
||||
{
|
||||
name: 'invalid take format',
|
||||
request: {
|
||||
skip: '0',
|
||||
take: 'not-a-number',
|
||||
},
|
||||
expectedErrorPath: ['take'],
|
||||
},
|
||||
{
|
||||
name: 'invalid sortBy value',
|
||||
request: {
|
||||
sortBy: 'invalid-value',
|
||||
},
|
||||
expectedErrorPath: ['sortBy'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ListInsightsWorkflowQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath && !result.success) {
|
||||
if (Array.isArray(expectedErrorPath)) {
|
||||
const errorPaths = result.error.issues[0].path;
|
||||
expect(errorPaths).toContain(expectedErrorPath[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
'total:asc',
|
||||
'total:desc',
|
||||
'succeeded:asc',
|
||||
'succeeded:desc',
|
||||
'failed:asc',
|
||||
'failed:desc',
|
||||
'timeSaved:asc',
|
||||
'timeSaved:desc',
|
||||
'runTime:asc',
|
||||
'runTime:desc',
|
||||
'averageRunTime:asc',
|
||||
'averageRunTime:desc',
|
||||
] as const;
|
||||
|
||||
// ---------------------
|
||||
// Parameter Validators
|
||||
// ---------------------
|
||||
|
||||
// Skip parameter validation
|
||||
const skipValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 0))
|
||||
.refine((val) => !isNaN(val), {
|
||||
message: 'Skip must be a valid number',
|
||||
});
|
||||
|
||||
// Take parameter validation
|
||||
const takeValidator = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val ? parseInt(val, 10) : 10))
|
||||
.refine((val) => !isNaN(val), {
|
||||
message: 'Take must be a valid number',
|
||||
});
|
||||
|
||||
// SortBy parameter validation
|
||||
const sortByValidator = z
|
||||
.enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` })
|
||||
.optional();
|
||||
|
||||
export class ListInsightsWorkflowQueryDto extends Z.class({
|
||||
skip: skipValidator,
|
||||
take: takeValidator,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
|
|
@ -32,4 +32,6 @@ export type {
|
|||
InsightsSummaryType,
|
||||
InsightsSummaryUnit,
|
||||
InsightsSummary,
|
||||
InsightsByWorkflow,
|
||||
InsightsByTime,
|
||||
} from './schemas/insights.schema';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { insightsSummarySchema } from '../insights.schema';
|
||||
import {
|
||||
insightsByTimeSchema,
|
||||
insightsByWorkflowSchema,
|
||||
insightsSummarySchema,
|
||||
} from '../insights.schema';
|
||||
|
||||
describe('insightsSummarySchema', () => {
|
||||
test.each([
|
||||
|
|
@ -62,3 +66,170 @@ describe('insightsSummarySchema', () => {
|
|||
expect(result.success).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insightsByWorkflowSchema', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid workflow insights',
|
||||
value: {
|
||||
count: 2,
|
||||
data: [
|
||||
{
|
||||
workflowId: 'w1',
|
||||
workflowName: 'Test Workflow',
|
||||
projectId: 'p1',
|
||||
projectName: 'Test Project',
|
||||
total: 100,
|
||||
succeeded: 90,
|
||||
failed: 10,
|
||||
failureRate: 0.56,
|
||||
runTime: 300,
|
||||
averageRunTime: 30.5,
|
||||
timeSaved: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'wrong data type',
|
||||
value: {
|
||||
count: 2,
|
||||
data: [
|
||||
{
|
||||
workflowId: 'w1',
|
||||
total: '100',
|
||||
succeeded: 90,
|
||||
failed: 10,
|
||||
failureRate: 10,
|
||||
runTime: 300,
|
||||
averageRunTime: 30,
|
||||
timeSaved: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'missing required field',
|
||||
value: {
|
||||
count: 2,
|
||||
data: [
|
||||
{
|
||||
workflowId: 'w1',
|
||||
total: 100,
|
||||
succeeded: 90,
|
||||
failed: 10,
|
||||
failureRate: 10,
|
||||
runTime: 300,
|
||||
averageRunTime: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'unexpected key',
|
||||
value: {
|
||||
count: 2,
|
||||
data: [
|
||||
{
|
||||
workflowId: 'w1',
|
||||
total: 100,
|
||||
succeeded: 90,
|
||||
failed: 10,
|
||||
failureRate: 10,
|
||||
runTime: 300,
|
||||
averageRunTime: 30,
|
||||
timeSaved: 50,
|
||||
extraKey: 'value',
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
])('should validate $name', ({ value, expected }) => {
|
||||
const result = insightsByWorkflowSchema.safeParse(value);
|
||||
expect(result.success).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insightsByTimeSchema', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid insights by time',
|
||||
value: {
|
||||
date: '2025-03-25T10:34:36.484Z',
|
||||
values: {
|
||||
total: 200,
|
||||
succeeded: 180,
|
||||
failed: 20,
|
||||
failureRate: 10,
|
||||
averageRunTime: 40,
|
||||
timeSaved: 100,
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'invalid date format',
|
||||
value: {
|
||||
date: '20240325', // Should be a string
|
||||
values: {
|
||||
total: 200,
|
||||
succeeded: 180,
|
||||
failed: 20,
|
||||
failureRate: 10,
|
||||
averageRunTime: 40,
|
||||
timeSaved: 100,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'invalid field type',
|
||||
value: {
|
||||
date: 20240325, // Should be a string
|
||||
values: {
|
||||
total: 200,
|
||||
succeeded: 180,
|
||||
failed: 20,
|
||||
failureRate: 10,
|
||||
averageRunTime: 40,
|
||||
timeSaved: 100,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'missing required key',
|
||||
value: {
|
||||
date: '2025-03-25T10:34:36.484Z',
|
||||
values: {
|
||||
total: 200,
|
||||
succeeded: 180,
|
||||
failed: 20,
|
||||
failureRate: 10,
|
||||
averageRunTime: 40,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'unexpected key',
|
||||
value: {
|
||||
date: '2025-03-25T10:34:36.484Z',
|
||||
values: {
|
||||
total: 200,
|
||||
failureRate: 10,
|
||||
averageRunTime: 40,
|
||||
extraKey: 'value',
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
])('should validate $name', ({ value, expected }) => {
|
||||
const result = insightsByTimeSchema.safeParse(value);
|
||||
expect(result.success).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,3 +42,46 @@ export const insightsSummaryDataSchemas = {
|
|||
|
||||
export const insightsSummarySchema = z.object(insightsSummaryDataSchemas).strict();
|
||||
export type InsightsSummary = z.infer<typeof insightsSummarySchema>;
|
||||
|
||||
export const insightsByWorkflowDataSchemas = {
|
||||
count: z.number(),
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
workflowId: z.string(),
|
||||
workflowName: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
projectName: z.string().optional(),
|
||||
total: z.number(),
|
||||
succeeded: z.number(),
|
||||
failed: z.number(),
|
||||
failureRate: z.number(),
|
||||
runTime: z.number(),
|
||||
averageRunTime: z.number(),
|
||||
timeSaved: z.number(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
} as const;
|
||||
|
||||
export const insightsByWorkflowSchema = z.object(insightsByWorkflowDataSchemas).strict();
|
||||
export type InsightsByWorkflow = z.infer<typeof insightsByWorkflowSchema>;
|
||||
|
||||
export const insightsByTimeDataSchemas = {
|
||||
date: z.string().refine((val) => !isNaN(Date.parse(val)) && new Date(val).toISOString() === val, {
|
||||
message: 'Invalid date format, must be ISO 8601 format',
|
||||
}),
|
||||
values: z
|
||||
.object({
|
||||
total: z.number(),
|
||||
succeeded: z.number(),
|
||||
failed: z.number(),
|
||||
failureRate: z.number(),
|
||||
averageRunTime: z.number(),
|
||||
timeSaved: z.number(),
|
||||
})
|
||||
.strict(),
|
||||
} as const;
|
||||
|
||||
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
|
||||
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.32.0",
|
||||
"version": "1.33.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class XAiApi implements ICredentialType {
|
||||
name = 'xAiApi';
|
||||
|
||||
displayName = 'xAi';
|
||||
|
||||
documentationUrl = 'xAi';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'hidden',
|
||||
default: 'https://api.x.ai/v1',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.url }}',
|
||||
url: '/models',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ function getInputs(
|
|||
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
|
||||
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -130,6 +131,7 @@ function getInputs(
|
|||
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
|
||||
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const configuredOutputs = (parameters: INodeParameters, defaultCategories: strin
|
|||
const categories = (options?.categories as string) ?? defaultCategories;
|
||||
const categoriesArray = categories.split(',').map((cat) => cat.trim());
|
||||
|
||||
const ret = categoriesArray.map((cat) => ({ type: NodeConnectionTypes.Main, displayName: cat }));
|
||||
const ret = categoriesArray.map((cat) => ({ type: 'main', displayName: cat }));
|
||||
return ret;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ const configuredOutputs = (parameters: INodeParameters) => {
|
|||
const categories = ((parameters.categories as IDataObject)?.categories as IDataObject[]) ?? [];
|
||||
const fallback = (parameters.options as IDataObject)?.fallback as string;
|
||||
const ret = categories.map((cat) => {
|
||||
return { type: NodeConnectionTypes.Main, displayName: cat.category };
|
||||
return { type: 'main', displayName: cat.category };
|
||||
});
|
||||
if (fallback === 'other') ret.push({ type: NodeConnectionTypes.Main, displayName: 'Other' });
|
||||
if (fallback === 'other') ret.push({ type: 'main', displayName: 'Other' });
|
||||
return ret;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
|
||||
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
|
||||
export class LmChatXAiGrok implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'xAI Grok Chat Model',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
|
||||
name: 'lmChatXAiGrok',
|
||||
icon: { light: 'file:logo.dark.svg', dark: 'file:logo.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'xAI Grok Chat Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Chat Models (Recommended)'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatxaigrok/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
||||
inputs: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'xAiApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials?.url }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://docs.x.ai/docs/models">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'grok-2-vision-1212',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'responseFormat',
|
||||
default: 'text',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description: 'Regular text response',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json_object',
|
||||
description:
|
||||
'Enables JSON mode, which should guarantee the message the model generates is valid JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presencePenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
default: 360000,
|
||||
description: 'Maximum amount of time a request is allowed to take in milliseconds',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Retries',
|
||||
name: 'maxRetries',
|
||||
default: 2,
|
||||
description: 'Maximum number of retries to attempt',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top P',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<OpenAICompatibleCredential>('xAiApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries: number;
|
||||
timeout: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
responseFormat?: 'text' | 'json_object';
|
||||
};
|
||||
|
||||
const configuration: ClientOptions = {
|
||||
baseURL: credentials.url,
|
||||
};
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
openAIApiKey: credentials.apiKey,
|
||||
modelName,
|
||||
...options,
|
||||
timeout: options.timeout ?? 60000,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs: options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true" class="" focusable="false" style="fill: currentcolor; height: 28px; width: 28px;"><path d="m3.005 8.858 8.783 12.544h3.904L6.908 8.858zM6.905 15.825 3 21.402h3.907l1.951-2.788zM16.585 2l-6.75 9.64 1.953 2.79L20.492 2zM17.292 7.965v13.437h3.2V3.395z"></path></svg>
|
||||
|
After Width: | Height: | Size: 363 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="32" height="32"><g><polygon fill="#fff" points="226.83 411.15 501.31 803.15 623.31 803.15 348.82 411.15 226.83 411.15"></polygon><polygon fill="#fff" points="348.72 628.87 226.69 803.15 348.77 803.15 409.76 716.05 348.72 628.87"></polygon><polygon fill="#fff" points="651.23 196.85 440.28 498.12 501.32 585.29 773.31 196.85 651.23 196.85"></polygon><polygon fill="#fff" points="673.31 383.25 673.31 803.15 773.31 803.15 773.31 240.44 673.31 383.25"></polygon></g></svg>
|
||||
|
After Width: | Height: | Size: 541 B |
|
|
@ -50,25 +50,22 @@ const configureNodeInputs = (
|
|||
) => {
|
||||
if (resource === 'assistant' && operation === 'message') {
|
||||
const inputs: INodeInputConfiguration[] = [
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiTool, displayName: 'Tools' },
|
||||
{ type: 'main' },
|
||||
{ type: 'ai_tool', displayName: 'Tools' },
|
||||
];
|
||||
if (memory !== 'threadId') {
|
||||
inputs.push({ type: NodeConnectionTypes.AiMemory, displayName: 'Memory', maxConnections: 1 });
|
||||
inputs.push({ type: 'ai_memory', displayName: 'Memory', maxConnections: 1 });
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
if (resource === 'text' && operation === 'message') {
|
||||
if (hideTools === 'hide') {
|
||||
return [NodeConnectionTypes.Main];
|
||||
return ['main'];
|
||||
}
|
||||
return [
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiTool, displayName: 'Tools' },
|
||||
];
|
||||
return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }];
|
||||
}
|
||||
|
||||
return [NodeConnectionTypes.Main];
|
||||
return ['main'];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-missing-subtitle
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
"dist/credentials/QdrantApi.credentials.js",
|
||||
"dist/credentials/SerpApi.credentials.js",
|
||||
"dist/credentials/WolframAlphaApi.credentials.js",
|
||||
"dist/credentials/XAiApi.credentials.js",
|
||||
"dist/credentials/XataApi.credentials.js",
|
||||
"dist/credentials/ZepApi.credentials.js"
|
||||
],
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"dist/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.js",
|
||||
"dist/nodes/llms/LMChatOllama/LmChatOllama.node.js",
|
||||
"dist/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.js",
|
||||
"dist/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.js",
|
||||
"dist/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.js",
|
||||
"dist/nodes/llms/LMOpenAi/LmOpenAi.node.js",
|
||||
"dist/nodes/llms/LMCohere/LmCohere.node.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/permissions",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/utils",
|
||||
"type": "module",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -195,3 +195,5 @@ export const WsStatusCodes = {
|
|||
} as const;
|
||||
|
||||
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
|
||||
|
||||
export const EVALUATION_METRICS_NODE = `${NODE_PACKAGE_PREFIX}base.evaluationMetrics`;
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ import { WorkflowEntity } from './workflow-entity';
|
|||
import { WorkflowHistory } from './workflow-history';
|
||||
import { WorkflowStatistics } from './workflow-statistics';
|
||||
import { WorkflowTagMapping } from './workflow-tag-mapping';
|
||||
import { InsightsByPeriod } from '../../modules/insights/entities/insights-by-period';
|
||||
import { InsightsMetadata } from '../../modules/insights/entities/insights-metadata';
|
||||
import { InsightsRaw } from '../../modules/insights/entities/insights-raw';
|
||||
import { InsightsByPeriod } from '../../modules/insights/database/entities/insights-by-period';
|
||||
import { InsightsMetadata } from '../../modules/insights/database/entities/insights-metadata';
|
||||
import { InsightsRaw } from '../../modules/insights/database/entities/insights-raw';
|
||||
|
||||
export const entities = {
|
||||
AnnotationTagEntity,
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
import express from 'express';
|
||||
|
||||
import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
|
||||
import { Delete, Get, Patch, Post, RestController } from '@/decorators';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import {
|
||||
testMetricCreateRequestBodySchema,
|
||||
testMetricPatchRequestBodySchema,
|
||||
} from '@/evaluation.ee/metric.schema';
|
||||
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { TestDefinitionService } from './test-definition.service.ee';
|
||||
import { TestMetricsRequest } from './test-definitions.types.ee';
|
||||
|
||||
@RestController('/evaluation/test-definitions')
|
||||
export class TestMetricsController {
|
||||
constructor(
|
||||
private readonly testDefinitionService: TestDefinitionService,
|
||||
private readonly testMetricRepository: TestMetricRepository,
|
||||
private readonly telemetry: Telemetry,
|
||||
) {}
|
||||
|
||||
// This method is used in multiple places in the controller to get the test definition
|
||||
// (or just check that it exists and the user has access to it).
|
||||
private async getTestDefinition(
|
||||
req:
|
||||
| TestMetricsRequest.GetOne
|
||||
| TestMetricsRequest.GetMany
|
||||
| TestMetricsRequest.Patch
|
||||
| TestMetricsRequest.Delete
|
||||
| TestMetricsRequest.Create,
|
||||
) {
|
||||
const { testDefinitionId } = req.params;
|
||||
|
||||
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
const testDefinition = await this.testDefinitionService.findOne(
|
||||
testDefinitionId,
|
||||
userAccessibleWorkflowIds,
|
||||
);
|
||||
|
||||
if (!testDefinition) throw new NotFoundError('Test definition not found');
|
||||
|
||||
return testDefinition;
|
||||
}
|
||||
|
||||
@Get('/:testDefinitionId/metrics')
|
||||
async getMany(req: TestMetricsRequest.GetMany) {
|
||||
const { testDefinitionId } = req.params;
|
||||
|
||||
await this.getTestDefinition(req);
|
||||
|
||||
return await this.testMetricRepository.find({
|
||||
where: { testDefinition: { id: testDefinitionId } },
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/:testDefinitionId/metrics/:id')
|
||||
async getOne(req: TestMetricsRequest.GetOne) {
|
||||
const { id: metricId, testDefinitionId } = req.params;
|
||||
|
||||
await this.getTestDefinition(req);
|
||||
|
||||
const metric = await this.testMetricRepository.findOne({
|
||||
where: { id: metricId, testDefinition: { id: testDefinitionId } },
|
||||
});
|
||||
|
||||
if (!metric) throw new NotFoundError('Metric not found');
|
||||
|
||||
return metric;
|
||||
}
|
||||
|
||||
@Post('/:testDefinitionId/metrics')
|
||||
async create(req: TestMetricsRequest.Create, res: express.Response) {
|
||||
const bodyParseResult = testMetricCreateRequestBodySchema.safeParse(req.body);
|
||||
if (!bodyParseResult.success) {
|
||||
res.status(400).json({ errors: bodyParseResult.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const testDefinition = await this.getTestDefinition(req);
|
||||
|
||||
const metric = this.testMetricRepository.create({
|
||||
...req.body,
|
||||
testDefinition,
|
||||
});
|
||||
|
||||
return await this.testMetricRepository.save(metric);
|
||||
}
|
||||
|
||||
@Patch('/:testDefinitionId/metrics/:id')
|
||||
async patch(req: TestMetricsRequest.Patch, res: express.Response) {
|
||||
const { id: metricId, testDefinitionId } = req.params;
|
||||
|
||||
const bodyParseResult = testMetricPatchRequestBodySchema.safeParse(req.body);
|
||||
if (!bodyParseResult.success) {
|
||||
res.status(400).json({ errors: bodyParseResult.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.getTestDefinition(req);
|
||||
|
||||
const metric = await this.testMetricRepository.findOne({
|
||||
where: { id: metricId, testDefinition: { id: testDefinitionId } },
|
||||
});
|
||||
|
||||
if (!metric) throw new NotFoundError('Metric not found');
|
||||
|
||||
const updateResult = await this.testMetricRepository.update(metricId, bodyParseResult.data);
|
||||
|
||||
// Send telemetry event if the metric was updated
|
||||
if (updateResult.affected === 1 && metric.name !== bodyParseResult.data.name) {
|
||||
this.telemetry.track('User added metrics to test', {
|
||||
metric_id: metricId,
|
||||
metric_name: bodyParseResult.data.name,
|
||||
test_id: testDefinitionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Respond with the updated metric
|
||||
return await this.testMetricRepository.findOneBy({ id: metricId });
|
||||
}
|
||||
|
||||
@Delete('/:testDefinitionId/metrics/:id')
|
||||
async delete(req: TestMetricsRequest.Delete) {
|
||||
const { id: metricId, testDefinitionId } = req.params;
|
||||
|
||||
await this.getTestDefinition(req);
|
||||
|
||||
const metric = await this.testMetricRepository.findOne({
|
||||
where: { id: metricId, testDefinition: { id: testDefinitionId } },
|
||||
});
|
||||
|
||||
if (!metric) throw new NotFoundError('Metric not found');
|
||||
|
||||
await this.testMetricRepository.delete(metricId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -47,36 +47,6 @@ export declare namespace TestDefinitionsRequest {
|
|||
>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /test-definitions/:testDefinitionId/metrics
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace TestMetricsRequest {
|
||||
namespace RouteParams {
|
||||
type TestDefinitionId = {
|
||||
testDefinitionId: string;
|
||||
};
|
||||
|
||||
type TestMetricId = {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
type GetOne = AuthenticatedRequest<RouteParams.TestDefinitionId & RouteParams.TestMetricId>;
|
||||
|
||||
type GetMany = AuthenticatedRequest<RouteParams.TestDefinitionId>;
|
||||
|
||||
type Create = AuthenticatedRequest<RouteParams.TestDefinitionId, {}, { name: string }>;
|
||||
|
||||
type Patch = AuthenticatedRequest<
|
||||
RouteParams.TestDefinitionId & RouteParams.TestMetricId,
|
||||
{},
|
||||
{ name: string }
|
||||
>;
|
||||
|
||||
type Delete = AuthenticatedRequest<RouteParams.TestDefinitionId & RouteParams.TestMetricId>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /test-definitions/:testDefinitionId/runs
|
||||
// ----------------------------------
|
||||
|
|
|
|||
|
|
@ -24,14 +24,6 @@ describe('EvaluationMetrics', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should throw when missing values', () => {
|
||||
const testMetricNames = new Set(['metric1', 'metric2']);
|
||||
const metrics = new EvaluationMetrics(testMetricNames);
|
||||
|
||||
expect(() => metrics.addResults({ metric1: 1 })).toThrow('METRICS_MISSING');
|
||||
expect(() => metrics.addResults({ metric2: 0.2 })).toThrow('METRICS_MISSING');
|
||||
});
|
||||
|
||||
test('should handle empty metrics', () => {
|
||||
const testMetricNames = new Set(['metric1', 'metric2']);
|
||||
const metrics = new EvaluationMetrics(testMetricNames);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "6dde1608-135f-441d-8438-40f605e4dae3",
|
||||
"name": "Execute Workflow Trigger",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [-180, -40]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"metrics": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "f1cab1e4-dabc-4750-a1f7-6669a60213c9",
|
||||
"name": "metric1",
|
||||
"value": 20,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"id": "a66ed953-5341-47f6-8565-47640d987f5f",
|
||||
"name": "metric2",
|
||||
"value": 30,
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "dfc71b70-7b7a-4dde-914f-cc4ffc99b18c",
|
||||
"name": "Success",
|
||||
"type": "n8n-nodes-base.evaluationMetrics",
|
||||
"typeVersion": 1,
|
||||
"position": [620, -40]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"metrics": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "1e1153da-77c0-4cb5-804a-3f4bc40833dd",
|
||||
"name": "metric2",
|
||||
"value": 10,
|
||||
"type": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "5ef5be33-37e0-4c95-81c8-3fd677bdca88",
|
||||
"name": "First Metric",
|
||||
"type": "n8n-nodes-base.evaluationMetrics",
|
||||
"typeVersion": 1,
|
||||
"position": [160, -40]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Execute Workflow Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "First Metric",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"First Metric": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Success",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"metrics": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "3b65d55a-158f-40c6-9853-a1c44b7ba1e5",
|
||||
|
|
@ -70,13 +70,13 @@
|
|||
},
|
||||
"id": "0c7a1ee8-0cf0-4d7f-99a3-186bbcd8815a",
|
||||
"name": "Success",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"type": "n8n-nodes-base.evaluationMetrics",
|
||||
"typeVersion": 1,
|
||||
"position": [980, 220]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"metrics": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "6cc8b402-4a30-4873-b825-963a1f1b8b82",
|
||||
|
|
@ -90,8 +90,8 @@
|
|||
},
|
||||
"id": "50d3f84a-d99f-4e04-bdbd-3e8c2668e708",
|
||||
"name": "Fail",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"type": "n8n-nodes-base.evaluationMetrics",
|
||||
"typeVersion": 1,
|
||||
"position": [980, 420]
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ const wfEvaluationJson = JSON.parse(
|
|||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const wfEvaluationMiddleJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation-middle.json'), {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
|
||||
const wfMultipleTriggersJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.multiple-triggers.json'), {
|
||||
encoding: 'utf-8',
|
||||
|
|
@ -131,9 +137,22 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
|||
return mock<IRun>({
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'lastNode',
|
||||
lastNodeExecuted: 'Success',
|
||||
runData: {
|
||||
lastNode: [
|
||||
Success: [
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: metrics,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
Fail: [
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
|
|
@ -155,6 +174,52 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
|||
});
|
||||
}
|
||||
|
||||
function mockEvaluationMiddleExecutionData(
|
||||
firstMetrics: Record<string, GenericValue>,
|
||||
secondMetrics: Record<string, GenericValue>,
|
||||
) {
|
||||
// Clone the metrics to avoid modifying the passed object
|
||||
// For test assertions, these run-data need special handling
|
||||
const runData: Record<string, any> = {
|
||||
'First Metric': [
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: firstMetrics,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
Success: [
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: secondMetrics,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return mock<IRun>({
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'Success',
|
||||
runData,
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
const logger = mockLogger();
|
||||
const telemetry = mock<Telemetry>();
|
||||
|
|
@ -363,7 +428,6 @@ describe('TestRunnerService', () => {
|
|||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||
metric1: 0.75,
|
||||
metric2: 50,
|
||||
});
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -868,6 +932,218 @@ describe('TestRunnerService', () => {
|
|||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should run workflow with metrics defined in the middle of the workflow', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
logger,
|
||||
telemetry,
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testCaseExecutionRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
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',
|
||||
...wfEvaluationMiddleJson,
|
||||
});
|
||||
|
||||
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(mockEvaluationMiddleExecutionData({ metric2: 1 }, { metric1: 1 }));
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockResolvedValue(mockEvaluationMiddleExecutionData({ metric2: 2 }, { metric1: 0.5 }));
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Check workflow under test was executed
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executionMode: 'evaluation',
|
||||
pinData: {
|
||||
'When clicking ‘Test workflow’':
|
||||
executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0],
|
||||
},
|
||||
workflowData: expect.objectContaining({
|
||||
id: 'workflow-under-test-id',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Check evaluation workflow was executed
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executionMode: 'integrated',
|
||||
executionData: expect.objectContaining({
|
||||
executionData: expect.objectContaining({
|
||||
nodeExecutionStack: expect.arrayContaining([
|
||||
expect.objectContaining({ data: expect.anything() }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
workflowData: expect.objectContaining({
|
||||
id: 'evaluation-workflow-id',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Check Test Run status was updated correctly
|
||||
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||
metric1: 0.75,
|
||||
metric2: 1.5,
|
||||
});
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
|
||||
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should properly override metrics from earlier nodes with later ones', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
logger,
|
||||
telemetry,
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testCaseExecutionRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
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',
|
||||
...wfEvaluationMiddleJson,
|
||||
});
|
||||
|
||||
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(
|
||||
mockEvaluationMiddleExecutionData({ metric2: 5 }, { metric1: 1, metric2: 5 }),
|
||||
);
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockResolvedValue(
|
||||
mockEvaluationMiddleExecutionData({ metric2: 10 }, { metric1: 0.5, metric2: 10 }),
|
||||
);
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Check workflow under test was executed
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executionMode: 'evaluation',
|
||||
pinData: {
|
||||
'When clicking ‘Test workflow’':
|
||||
executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0],
|
||||
},
|
||||
workflowData: expect.objectContaining({
|
||||
id: 'workflow-under-test-id',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Check evaluation workflow was executed
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executionMode: 'integrated',
|
||||
executionData: expect.objectContaining({
|
||||
executionData: expect.objectContaining({
|
||||
nodeExecutionStack: expect.arrayContaining([
|
||||
expect.objectContaining({ data: expect.anything() }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
workflowData: expect.objectContaining({
|
||||
id: 'evaluation-workflow-id',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Check Test Run status was updated correctly
|
||||
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||
metric1: 0.75,
|
||||
metric2: 7.5,
|
||||
});
|
||||
|
||||
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
|
||||
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Test Run cancellation', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ export type TestCaseExecutionErrorCode =
|
|||
| 'FAILED_TO_EXECUTE_WORKFLOW'
|
||||
| 'EVALUATION_WORKFLOW_DOES_NOT_EXIST'
|
||||
| 'FAILED_TO_EXECUTE_EVALUATION_WORKFLOW'
|
||||
| 'METRICS_MISSING'
|
||||
| 'UNKNOWN_METRICS'
|
||||
| 'INVALID_METRICS'
|
||||
| 'PAYLOAD_LIMIT_EXCEEDED'
|
||||
| 'UNKNOWN_ERROR';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import difference from 'lodash/difference';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { TestCaseExecutionError } from '@/evaluation.ee/test-runner/errors.ee';
|
||||
|
|
@ -43,16 +42,6 @@ export class EvaluationMetrics {
|
|||
}
|
||||
}
|
||||
|
||||
// Check that result contains all expected metrics
|
||||
if (
|
||||
difference(Array.from(this.metricNames), Object.keys(addResultsInfo.addedMetrics)).length > 0
|
||||
) {
|
||||
throw new TestCaseExecutionError('METRICS_MISSING', {
|
||||
expectedMetrics: Array.from(this.metricNames).sort(),
|
||||
receivedMetrics: Object.keys(addResultsInfo.addedMetrics).sort(),
|
||||
});
|
||||
}
|
||||
|
||||
return addResultsInfo;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { parse } from 'flatted';
|
||||
import difference from 'lodash/difference';
|
||||
import { ErrorReporter, Logger } from 'n8n-core';
|
||||
import { ExecutionCancelledError, NodeConnectionTypes, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
AssignmentCollectionValue,
|
||||
IDataObject,
|
||||
IRun,
|
||||
IRunExecutionData,
|
||||
|
|
@ -13,6 +15,7 @@ import assert from 'node:assert';
|
|||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import config from '@/config';
|
||||
import { EVALUATION_METRICS_NODE } from '@/constants';
|
||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||
import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
import type { TestRun } from '@/databases/entities/test-run.ee';
|
||||
|
|
@ -225,6 +228,50 @@ export class TestRunnerService {
|
|||
return await executePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the metrics of the test definition with the evaluation workflow.
|
||||
*/
|
||||
async syncMetrics(
|
||||
testDefinitionId: string,
|
||||
evaluationWorkflow: IWorkflowBase,
|
||||
): Promise<Set<string>> {
|
||||
const usedTestMetricNames = await this.getUsedTestMetricNames(evaluationWorkflow);
|
||||
const existingTestMetrics = await this.testMetricRepository.find({
|
||||
where: {
|
||||
testDefinition: { id: testDefinitionId },
|
||||
},
|
||||
});
|
||||
|
||||
const existingMetricNames = new Set(existingTestMetrics.map((metric) => metric.name));
|
||||
const metricsToAdd = difference(
|
||||
Array.from(usedTestMetricNames),
|
||||
Array.from(existingMetricNames),
|
||||
);
|
||||
const metricsToRemove = difference(
|
||||
Array.from(existingMetricNames),
|
||||
Array.from(usedTestMetricNames),
|
||||
);
|
||||
|
||||
// Add new metrics
|
||||
const metricsToAddEntities = metricsToAdd.map((metricName) =>
|
||||
this.testMetricRepository.create({
|
||||
name: metricName,
|
||||
testDefinition: { id: testDefinitionId },
|
||||
}),
|
||||
);
|
||||
await this.testMetricRepository.save(metricsToAddEntities);
|
||||
|
||||
// Remove no longer used metrics
|
||||
metricsToRemove.forEach(async (metricName) => {
|
||||
const metric = existingTestMetrics.find((m) => m.name === metricName);
|
||||
assert(metric, 'Existing metric not found');
|
||||
|
||||
await this.testMetricRepository.delete(metric.id);
|
||||
});
|
||||
|
||||
return usedTestMetricNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the evaluation workflow with the expected and actual run data.
|
||||
*/
|
||||
|
|
@ -265,35 +312,45 @@ export class TestRunnerService {
|
|||
return await executePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the evaluation metrics nodes from a workflow.
|
||||
*/
|
||||
static getEvaluationMetricsNodes(workflow: IWorkflowBase) {
|
||||
return workflow.nodes.filter((node) => node.type === EVALUATION_METRICS_NODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluation result is the first item in the output of the last node
|
||||
* executed in the evaluation workflow. Defaults to an empty object
|
||||
* in case the node doesn't produce any output items.
|
||||
*/
|
||||
private extractEvaluationResult(execution: IRun): IDataObject {
|
||||
private extractEvaluationResult(execution: IRun, evaluationWorkflow: IWorkflowBase): IDataObject {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow');
|
||||
const metricsNodes = TestRunnerService.getEvaluationMetricsNodes(evaluationWorkflow);
|
||||
const metricsRunData = metricsNodes.flatMap(
|
||||
(node) => execution.data.resultData.runData[node.name],
|
||||
);
|
||||
const metricsData = metricsRunData.reverse().map((data) => data.data?.main?.[0]?.[0]?.json);
|
||||
const metricsResult = metricsData.reduce((acc, curr) => ({ ...acc, ...curr }), {}) ?? {};
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
// We use only the first item of a first main output
|
||||
const lastNodeTaskData = execution.data.resultData.runData[lastNodeExecuted]?.[0];
|
||||
const mainConnectionData = lastNodeTaskData?.data?.main?.[0];
|
||||
return mainConnectionData?.[0]?.json ?? {};
|
||||
return metricsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the metrics to collect from the evaluation workflow execution results.
|
||||
*/
|
||||
private async getTestMetricNames(testDefinitionId: string) {
|
||||
const metrics = await this.testMetricRepository.find({
|
||||
where: {
|
||||
testDefinition: {
|
||||
id: testDefinitionId,
|
||||
},
|
||||
},
|
||||
private async getUsedTestMetricNames(evaluationWorkflow: IWorkflowBase) {
|
||||
const metricsNodes = TestRunnerService.getEvaluationMetricsNodes(evaluationWorkflow);
|
||||
const metrics = metricsNodes.map((node) => {
|
||||
const metricsParameter = node.parameters?.metrics as AssignmentCollectionValue;
|
||||
assert(metricsParameter, 'Metrics parameter not found');
|
||||
|
||||
const metricsNames = metricsParameter.assignments.map((assignment) => assignment.name);
|
||||
return metricsNames;
|
||||
});
|
||||
|
||||
return new Set(metrics.map((m) => m.name));
|
||||
return new Set(metrics.flat());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -329,7 +386,6 @@ export class TestRunnerService {
|
|||
if (!evaluationWorkflow) {
|
||||
throw new TestRunError('EVALUATION_WORKFLOW_NOT_FOUND');
|
||||
}
|
||||
|
||||
///
|
||||
// 1. Make test cases from previous executions
|
||||
///
|
||||
|
|
@ -359,8 +415,8 @@ export class TestRunnerService {
|
|||
pastExecutions.map((e) => e.id),
|
||||
);
|
||||
|
||||
// Get the metrics to collect from the evaluation workflow
|
||||
const testMetricNames = await this.getTestMetricNames(test.id);
|
||||
// Sync the metrics of the test definition with the evaluation workflow
|
||||
const testMetricNames = await this.syncMetrics(test.id, evaluationWorkflow);
|
||||
|
||||
// 2. Run over all the test cases
|
||||
const pastExecutionIds = pastExecutions.map((e) => e.id);
|
||||
|
|
@ -465,8 +521,8 @@ export class TestRunnerService {
|
|||
this.logger.debug('Evaluation execution finished', { pastExecutionId });
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
const { addedMetrics, unknownMetrics } = metrics.addResults(
|
||||
this.extractEvaluationResult(evalExecution),
|
||||
const { addedMetrics } = metrics.addResults(
|
||||
this.extractEvaluationResult(evalExecution, evaluationWorkflow),
|
||||
);
|
||||
|
||||
if (evalExecution.data.resultData.error) {
|
||||
|
|
@ -483,22 +539,12 @@ export class TestRunnerService {
|
|||
await Db.transaction(async (trx) => {
|
||||
await this.testRunRepository.incrementPassed(testRun.id, trx);
|
||||
|
||||
// Add warning if the evaluation workflow produced an unknown metric
|
||||
if (unknownMetrics.size > 0) {
|
||||
await this.testCaseExecutionRepository.markAsWarning({
|
||||
testRunId: testRun.id,
|
||||
pastExecutionId,
|
||||
errorCode: 'UNKNOWN_METRICS',
|
||||
errorDetails: { unknownMetrics: Array.from(unknownMetrics) },
|
||||
});
|
||||
} else {
|
||||
await this.testCaseExecutionRepository.markAsCompleted({
|
||||
testRunId: testRun.id,
|
||||
pastExecutionId,
|
||||
metrics: addedMetrics,
|
||||
trx,
|
||||
});
|
||||
}
|
||||
await this.testCaseExecutionRepository.markAsCompleted({
|
||||
testRunId: testRun.id,
|
||||
pastExecutionId,
|
||||
metrics: addedMetrics,
|
||||
trx,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { Container } from '@n8n/di';
|
||||
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import * as testDb from '@test-integration/test-db';
|
||||
|
||||
import { TypeToNumber } from '../database/entities/insights-shared';
|
||||
import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
|
||||
import { InsightsController } from '../insights.controller';
|
||||
|
||||
// Initialize DB once for all tests
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
// Terminate DB once after all tests complete
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('InsightsController', () => {
|
||||
const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository);
|
||||
let controller: InsightsController;
|
||||
beforeAll(async () => {
|
||||
controller = Container.get(InsightsController);
|
||||
});
|
||||
|
||||
describe('getInsightsSummary', () => {
|
||||
it('should return default insights if no data', async () => {
|
||||
// ARRANGE
|
||||
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
total: { deviation: 0, unit: 'count', value: 0 },
|
||||
failed: { deviation: 0, unit: 'count', value: 0 },
|
||||
failureRate: { deviation: 0, unit: 'ratio', value: 0 },
|
||||
averageRunTime: { deviation: 0, unit: 'time', value: 0 },
|
||||
timeSaved: { deviation: 0, unit: 'time', value: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the insights summary with deviation = current if insights exist only for current period', async () => {
|
||||
// ARRANGE
|
||||
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([
|
||||
{ period: 'current', type: TypeToNumber.success, total_value: 20 },
|
||||
{ period: 'current', type: TypeToNumber.failure, total_value: 10 },
|
||||
{ period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 },
|
||||
{ period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 },
|
||||
]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
total: { deviation: 30, unit: 'count', value: 30 },
|
||||
failed: { deviation: 10, unit: 'count', value: 10 },
|
||||
failureRate: { deviation: 0.33, unit: 'ratio', value: 0.33 },
|
||||
averageRunTime: { deviation: 10, unit: 'time', value: 10 },
|
||||
timeSaved: { deviation: 10, unit: 'time', value: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the insights summary if insights exist for both periods', async () => {
|
||||
// ARRANGE
|
||||
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([
|
||||
{ period: 'previous', type: TypeToNumber.success, total_value: 16 },
|
||||
{ period: 'previous', type: TypeToNumber.failure, total_value: 4 },
|
||||
{ period: 'previous', type: TypeToNumber.runtime_ms, total_value: 40 },
|
||||
{ period: 'previous', type: TypeToNumber.time_saved_min, total_value: 5 },
|
||||
{ period: 'current', type: TypeToNumber.success, total_value: 20 },
|
||||
{ period: 'current', type: TypeToNumber.failure, total_value: 10 },
|
||||
{ period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 },
|
||||
{ period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 },
|
||||
]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
total: { deviation: 10, unit: 'count', value: 30 },
|
||||
failed: { deviation: 6, unit: 'count', value: 10 },
|
||||
failureRate: { deviation: 0.33 - 0.2, unit: 'ratio', value: 0.33 },
|
||||
averageRunTime: { deviation: 300 / 30 - 40 / 20, unit: 'time', value: 10 },
|
||||
timeSaved: { deviation: 5, unit: 'time', value: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,15 +7,21 @@ import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
|||
import type { Project } from '@/databases/entities/project';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import type { IWorkflowDb } from '@/interfaces';
|
||||
import type { TypeUnits } from '@/modules/insights/entities/insights-shared';
|
||||
import { InsightsMetadataRepository } from '@/modules/insights/repositories/insights-metadata.repository';
|
||||
import { InsightsRawRepository } from '@/modules/insights/repositories/insights-raw.repository';
|
||||
import type { TypeUnit } from '@/modules/insights/database/entities/insights-shared';
|
||||
import { InsightsMetadataRepository } from '@/modules/insights/database/repositories/insights-metadata.repository';
|
||||
import { InsightsRawRepository } from '@/modules/insights/database/repositories/insights-raw.repository';
|
||||
import { createTeamProject } from '@test-integration/db/projects';
|
||||
import { createWorkflow } from '@test-integration/db/workflows';
|
||||
import * as testDb from '@test-integration/test-db';
|
||||
|
||||
import {
|
||||
createMetadata,
|
||||
createRawInsightsEvent,
|
||||
createCompactedInsightsEvent,
|
||||
createRawInsightsEvents,
|
||||
} from '../database/entities/__tests__/db-utils';
|
||||
import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
|
||||
import { InsightsService } from '../insights.service';
|
||||
import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository';
|
||||
|
||||
async function truncateAll() {
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
|
|
@ -30,13 +36,21 @@ async function truncateAll() {
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize DB once for all tests
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
// Terminate DB once after all tests complete
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('workflowExecuteAfterHandler', () => {
|
||||
let insightsService: InsightsService;
|
||||
let insightsRawRepository: InsightsRawRepository;
|
||||
let insightsMetadataRepository: InsightsMetadataRepository;
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
insightsService = Container.get(InsightsService);
|
||||
insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
insightsMetadataRepository = Container.get(InsightsMetadataRepository);
|
||||
|
|
@ -59,7 +73,7 @@ describe('workflowExecuteAfterHandler', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test.each<{ status: ExecutionStatus; type: TypeUnits }>([
|
||||
test.each<{ status: ExecutionStatus; type: TypeUnit }>([
|
||||
{ status: 'success', type: 'success' },
|
||||
{ status: 'error', type: 'failure' },
|
||||
{ status: 'crashed', type: 'failure' },
|
||||
|
|
@ -245,3 +259,418 @@ describe('workflowExecuteAfterHandler', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compaction', () => {
|
||||
beforeEach(async () => {
|
||||
await truncateAll();
|
||||
});
|
||||
|
||||
describe('compactRawToHour', () => {
|
||||
type TestData = {
|
||||
name: string;
|
||||
timestamps: DateTime[];
|
||||
batches: number[];
|
||||
};
|
||||
|
||||
test.each<TestData>([
|
||||
{
|
||||
name: 'compact into 2 rows',
|
||||
timestamps: [
|
||||
DateTime.utc(2000, 1, 1, 0, 0),
|
||||
DateTime.utc(2000, 1, 1, 0, 59),
|
||||
DateTime.utc(2000, 1, 1, 1, 0),
|
||||
],
|
||||
batches: [2, 1],
|
||||
},
|
||||
{
|
||||
name: 'compact into 3 rows',
|
||||
timestamps: [
|
||||
DateTime.utc(2000, 1, 1, 0, 0),
|
||||
DateTime.utc(2000, 1, 1, 1, 0),
|
||||
DateTime.utc(2000, 1, 1, 2, 0),
|
||||
],
|
||||
batches: [1, 1, 1],
|
||||
},
|
||||
])('$name', async ({ timestamps, batches }) => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow = await createWorkflow({}, project);
|
||||
// create before so we can create the raw events in parallel
|
||||
await createMetadata(workflow);
|
||||
for (const timestamp of timestamps) {
|
||||
await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp });
|
||||
}
|
||||
|
||||
// ACT
|
||||
const compactedRows = await insightsService.compactRawToHour();
|
||||
|
||||
// ASSERT
|
||||
expect(compactedRows).toBe(timestamps.length);
|
||||
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||
expect(allCompacted).toHaveLength(batches.length);
|
||||
for (const [index, compacted] of allCompacted.entries()) {
|
||||
expect(compacted.value).toBe(batches[index]);
|
||||
}
|
||||
});
|
||||
|
||||
test('batch compaction split events in hourly insight periods', async () => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow = await createWorkflow({}, project);
|
||||
|
||||
const batchSize = 100;
|
||||
|
||||
let timestamp = DateTime.utc().startOf('hour');
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp });
|
||||
// create 60 events per hour
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
|
||||
// ACT
|
||||
await insightsService.compactInsights();
|
||||
|
||||
// ASSERT
|
||||
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||
|
||||
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||
expect(accumulatedValues).toBe(batchSize);
|
||||
expect(allCompacted[0].value).toBe(60);
|
||||
expect(allCompacted[1].value).toBe(40);
|
||||
});
|
||||
|
||||
test('batch compaction split events in hourly insight periods by type and workflow', async () => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow1 = await createWorkflow({}, project);
|
||||
const workflow2 = await createWorkflow({}, project);
|
||||
|
||||
const batchSize = 100;
|
||||
|
||||
let timestamp = DateTime.utc().startOf('hour');
|
||||
for (let i = 0; i < batchSize / 4; i++) {
|
||||
await createRawInsightsEvent(workflow1, { type: 'success', value: 1, timestamp });
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
|
||||
for (let i = 0; i < batchSize / 4; i++) {
|
||||
await createRawInsightsEvent(workflow1, { type: 'failure', value: 1, timestamp });
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
|
||||
for (let i = 0; i < batchSize / 4; i++) {
|
||||
await createRawInsightsEvent(workflow2, { type: 'runtime_ms', value: 1200, timestamp });
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
|
||||
for (let i = 0; i < batchSize / 4; i++) {
|
||||
await createRawInsightsEvent(workflow2, { type: 'time_saved_min', value: 3, timestamp });
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
|
||||
// ACT
|
||||
await insightsService.compactInsights();
|
||||
|
||||
// ASSERT
|
||||
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||
|
||||
const allCompacted = await insightsByPeriodRepository.find({
|
||||
order: { metaId: 'ASC', periodStart: 'ASC' },
|
||||
});
|
||||
|
||||
// Expect 2 insights for workflow 1 (for success and failure)
|
||||
// and 3 for workflow 2 (2 period starts for runtime_ms and 1 for time_saved_min)
|
||||
expect(allCompacted).toHaveLength(5);
|
||||
const metaIds = allCompacted.map((event) => event.metaId);
|
||||
|
||||
// meta id are ordered. first 2 are for workflow 1, last 3 are for workflow 2
|
||||
const uniqueMetaIds = [metaIds[0], metaIds[2]];
|
||||
const workflow1Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[0]);
|
||||
const workflow2Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[1]);
|
||||
|
||||
expect(workflow1Insights).toHaveLength(2);
|
||||
expect(workflow2Insights).toHaveLength(3);
|
||||
|
||||
const successInsights = workflow1Insights.find((event) => event.type === 'success');
|
||||
const failureInsights = workflow1Insights.find((event) => event.type === 'failure');
|
||||
|
||||
expect(successInsights).toBeTruthy();
|
||||
expect(failureInsights).toBeTruthy();
|
||||
// success and failure insights should have the value matching the number or raw events (because value = 1)
|
||||
expect(successInsights!.value).toBe(25);
|
||||
expect(failureInsights!.value).toBe(25);
|
||||
|
||||
const runtimeMsEvents = workflow2Insights.filter((event) => event.type === 'runtime_ms');
|
||||
const timeSavedMinEvents = workflow2Insights.find((event) => event.type === 'time_saved_min');
|
||||
expect(runtimeMsEvents).toHaveLength(2);
|
||||
|
||||
// The last 10 minutes of the first hour
|
||||
expect(runtimeMsEvents[0].value).toBe(1200 * 10);
|
||||
|
||||
// The first 15 minutes of the second hour
|
||||
expect(runtimeMsEvents[1].value).toBe(1200 * 15);
|
||||
expect(timeSavedMinEvents).toBeTruthy();
|
||||
expect(timeSavedMinEvents!.value).toBe(3 * 25);
|
||||
});
|
||||
|
||||
test('should return the number of compacted events', async () => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow = await createWorkflow({}, project);
|
||||
|
||||
const batchSize = 100;
|
||||
|
||||
let timestamp = DateTime.utc(2000, 1, 1, 0, 0);
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp });
|
||||
// create 60 events per hour
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
|
||||
// ACT
|
||||
const numberOfCompactedData = await insightsService.compactRawToHour();
|
||||
|
||||
// ASSERT
|
||||
expect(numberOfCompactedData).toBe(100);
|
||||
});
|
||||
|
||||
test('works with data in the compacted table', async () => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow = await createWorkflow({}, project);
|
||||
|
||||
const batchSize = 100;
|
||||
|
||||
let timestamp = DateTime.utc().startOf('hour');
|
||||
|
||||
// Create an existing compacted event for the first hour
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 10,
|
||||
periodUnit: 'hour',
|
||||
periodStart: timestamp,
|
||||
});
|
||||
|
||||
const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>();
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
events.push({ type: 'success', value: 1, timestamp });
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
await createRawInsightsEvents(workflow, events);
|
||||
|
||||
// ACT
|
||||
await insightsService.compactInsights();
|
||||
|
||||
// ASSERT
|
||||
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||
|
||||
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||
expect(accumulatedValues).toBe(batchSize + 10);
|
||||
expect(allCompacted[0].value).toBe(70);
|
||||
expect(allCompacted[1].value).toBe(40);
|
||||
});
|
||||
|
||||
test('works with data bigger than the batch size', async () => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
|
||||
// spy on the compactRawToHour method to check if it's called multiple times
|
||||
const rawToHourSpy = jest.spyOn(insightsService, 'compactRawToHour');
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow = await createWorkflow({}, project);
|
||||
|
||||
const batchSize = 600;
|
||||
|
||||
let timestamp = DateTime.utc().startOf('hour');
|
||||
const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>();
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
events.push({ type: 'success', value: 1, timestamp });
|
||||
timestamp = timestamp.plus({ minute: 1 });
|
||||
}
|
||||
await createRawInsightsEvents(workflow, events);
|
||||
|
||||
// ACT
|
||||
await insightsService.compactInsights();
|
||||
|
||||
// ASSERT
|
||||
expect(rawToHourSpy).toHaveBeenCalledTimes(3);
|
||||
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||
expect(accumulatedValues).toBe(batchSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactionSchedule', () => {
|
||||
test('compaction is running on schedule', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
insightsService.initializeCompaction();
|
||||
|
||||
// spy on the compactInsights method to check if it's called
|
||||
insightsService.compactInsights = jest.fn();
|
||||
|
||||
// ACT
|
||||
// advance by 1 hour and 1 minute
|
||||
jest.advanceTimersByTime(1000 * 60 * 61);
|
||||
|
||||
// ASSERT
|
||||
expect(insightsService.compactInsights).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactHourToDay', () => {
|
||||
type TestData = {
|
||||
name: string;
|
||||
periodStarts: DateTime[];
|
||||
batches: number[];
|
||||
};
|
||||
|
||||
test.each<TestData>([
|
||||
{
|
||||
name: 'compact into 2 rows',
|
||||
periodStarts: [
|
||||
DateTime.utc(2000, 1, 1, 0, 0),
|
||||
DateTime.utc(2000, 1, 1, 23, 59),
|
||||
DateTime.utc(2000, 1, 2, 1, 0),
|
||||
],
|
||||
batches: [2, 1],
|
||||
},
|
||||
{
|
||||
name: 'compact into 3 rows',
|
||||
periodStarts: [
|
||||
DateTime.utc(2000, 1, 1, 0, 0),
|
||||
DateTime.utc(2000, 1, 1, 23, 59),
|
||||
DateTime.utc(2000, 1, 2, 0, 0),
|
||||
DateTime.utc(2000, 1, 2, 23, 59),
|
||||
DateTime.utc(2000, 1, 3, 23, 59),
|
||||
],
|
||||
batches: [2, 2, 1],
|
||||
},
|
||||
])('$name', async ({ periodStarts, batches }) => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
|
||||
const project = await createTeamProject();
|
||||
const workflow = await createWorkflow({}, project);
|
||||
// create before so we can create the raw events in parallel
|
||||
await createMetadata(workflow);
|
||||
for (const periodStart of periodStarts) {
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'hour',
|
||||
periodStart,
|
||||
});
|
||||
}
|
||||
|
||||
// ACT
|
||||
const compactedRows = await insightsService.compactHourToDay();
|
||||
|
||||
// ASSERT
|
||||
expect(compactedRows).toBe(periodStarts.length);
|
||||
await expect(insightsRawRepository.count()).resolves.toBe(0);
|
||||
const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } });
|
||||
expect(allCompacted).toHaveLength(batches.length);
|
||||
for (const [index, compacted] of allCompacted.entries()) {
|
||||
expect(compacted.value).toBe(batches[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInsightsSummary', () => {
|
||||
let insightsService: InsightsService;
|
||||
beforeAll(async () => {
|
||||
insightsService = Container.get(InsightsService);
|
||||
});
|
||||
|
||||
let project: Project;
|
||||
let workflow: IWorkflowDb & WorkflowEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAll();
|
||||
|
||||
project = await createTeamProject();
|
||||
workflow = await createWorkflow({}, project);
|
||||
});
|
||||
|
||||
test('compacted data are summarized correctly', async () => {
|
||||
// ARRANGE
|
||||
// last 7 days
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ day: 2 }),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'failure',
|
||||
value: 2,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc(),
|
||||
});
|
||||
// last 14 days
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 1,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
});
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'runtime_ms',
|
||||
value: 123,
|
||||
periodUnit: 'day',
|
||||
periodStart: DateTime.utc().minus({ days: 10 }),
|
||||
});
|
||||
|
||||
// ACT
|
||||
const summary = await insightsService.getInsightsSummary();
|
||||
|
||||
// ASSERT
|
||||
expect(summary).toEqual({
|
||||
averageRunTime: { deviation: -123, unit: 'time', value: 0 },
|
||||
failed: { deviation: 2, unit: 'count', value: 2 },
|
||||
failureRate: { deviation: 0.5, unit: 'ratio', value: 0.5 },
|
||||
timeSaved: { deviation: 0, unit: 'time', value: 0 },
|
||||
total: { deviation: 3, unit: 'count', value: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import type { IWorkflowBase } from 'n8n-workflow';
|
|||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||
|
||||
import { InsightsMetadata } from '../../entities/insights-metadata';
|
||||
import { InsightsRaw } from '../../entities/insights-raw';
|
||||
import { InsightsByPeriodRepository } from '../../repositories/insights-by-period.repository';
|
||||
import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository';
|
||||
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
||||
import { InsightsByPeriod } from '../insights-by-period';
|
||||
import { InsightsMetadata } from '../insights-metadata';
|
||||
import { InsightsRaw } from '../insights-raw';
|
||||
|
||||
async function getWorkflowSharing(workflow: IWorkflowBase) {
|
||||
return await Container.get(SharedWorkflowRepository).find({
|
||||
|
|
@ -62,3 +64,49 @@ export async function createRawInsightsEvent(
|
|||
}
|
||||
return await insightsRawRepository.save(event);
|
||||
}
|
||||
|
||||
export async function createRawInsightsEvents(
|
||||
workflow: WorkflowEntity,
|
||||
parametersArray: Array<{
|
||||
type: InsightsRaw['type'];
|
||||
value: number;
|
||||
timestamp?: DateTime;
|
||||
}>,
|
||||
) {
|
||||
const insightsRawRepository = Container.get(InsightsRawRepository);
|
||||
const metadata = await createMetadata(workflow);
|
||||
|
||||
const events = parametersArray.map((parameters) => {
|
||||
const event = new InsightsRaw();
|
||||
event.metaId = metadata.metaId;
|
||||
event.type = parameters.type;
|
||||
event.value = parameters.value;
|
||||
if (parameters.timestamp) {
|
||||
event.timestamp = parameters.timestamp.toUTC().toJSDate();
|
||||
}
|
||||
return event;
|
||||
});
|
||||
await insightsRawRepository.save(events);
|
||||
}
|
||||
|
||||
export async function createCompactedInsightsEvent(
|
||||
workflow: WorkflowEntity,
|
||||
parameters: {
|
||||
type: InsightsByPeriod['type'];
|
||||
value: number;
|
||||
periodUnit: InsightsByPeriod['periodUnit'];
|
||||
periodStart: DateTime;
|
||||
},
|
||||
) {
|
||||
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
|
||||
const metadata = await createMetadata(workflow);
|
||||
|
||||
const event = new InsightsByPeriod();
|
||||
event.metaId = metadata.metaId;
|
||||
event.type = parameters.type;
|
||||
event.value = parameters.value;
|
||||
event.periodUnit = parameters.periodUnit;
|
||||
event.periodStart = parameters.periodStart.toUTC().startOf(parameters.periodUnit).toJSDate();
|
||||
|
||||
return await insightsByPeriodRepository.save(event);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import * as testDb from '@test-integration/test-db';
|
|||
|
||||
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
||||
import { InsightsByPeriod } from '../insights-by-period';
|
||||
import type { PeriodUnits, TypeUnits } from '../insights-shared';
|
||||
import type { PeriodUnit, TypeUnit } from '../insights-shared';
|
||||
|
||||
let insightsRawRepository: InsightsRawRepository;
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ afterAll(async () => {
|
|||
});
|
||||
|
||||
describe('Insights By Period', () => {
|
||||
test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnits[])(
|
||||
test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnit[])(
|
||||
'`%s` can be serialized and deserialized correctly',
|
||||
(typeUnit) => {
|
||||
// ARRANGE
|
||||
|
|
@ -35,7 +35,7 @@ describe('Insights By Period', () => {
|
|||
expect(insightByPeriod.type).toBe(typeUnit);
|
||||
},
|
||||
);
|
||||
test.each(['hour', 'day', 'week'] satisfies PeriodUnits[])(
|
||||
test.each(['hour', 'day', 'week'] satisfies PeriodUnit[])(
|
||||
'`%s` can be serialized and deserialized correctly',
|
||||
(periodUnit) => {
|
||||
// ARRANGE
|
||||
|
|
@ -8,7 +8,7 @@ import * as testDb from '@test-integration/test-db';
|
|||
import { createMetadata, createRawInsightsEvent } from './db-utils';
|
||||
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
||||
import { InsightsRaw } from '../insights-raw';
|
||||
import type { TypeUnits } from '../insights-shared';
|
||||
import type { TypeUnit } from '../insights-shared';
|
||||
|
||||
let insightsRawRepository: InsightsRawRepository;
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ afterAll(async () => {
|
|||
});
|
||||
|
||||
describe('Insights Raw Entity', () => {
|
||||
test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnits[])(
|
||||
test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnit[])(
|
||||
'`%s` can be serialized and deserialized correctly',
|
||||
(typeUnit) => {
|
||||
// ARRANGE
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { PeriodUnits } from './insights-shared';
|
||||
import type { PeriodUnit } from './insights-shared';
|
||||
import {
|
||||
isValidPeriodNumber,
|
||||
isValidTypeNumber,
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
PeriodUnitToNumber,
|
||||
TypeToNumber,
|
||||
} from './insights-shared';
|
||||
import { datetimeColumnType } from '../../../databases/entities/abstract-entity';
|
||||
import { datetimeColumnType } from '../../../../databases/entities/abstract-entity';
|
||||
|
||||
@Entity()
|
||||
export class InsightsByPeriod extends BaseEntity {
|
||||
|
|
@ -53,7 +53,7 @@ export class InsightsByPeriod extends BaseEntity {
|
|||
return NumberToPeriodUnit[this.periodUnit_];
|
||||
}
|
||||
|
||||
set periodUnit(value: PeriodUnits) {
|
||||
set periodUnit(value: PeriodUnit) {
|
||||
this.periodUnit_ = PeriodUnitToNumber[value];
|
||||
}
|
||||
|
||||
|
|
@ -4,7 +4,7 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm
|
|||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { isValidTypeNumber, NumberToType, TypeToNumber } from './insights-shared';
|
||||
import { datetimeColumnType } from '../../../databases/entities/abstract-entity';
|
||||
import { datetimeColumnType } from '../../../../databases/entities/abstract-entity';
|
||||
|
||||
export const { type: dbType } = Container.get(GlobalConfig).database;
|
||||
|
||||
|
|
@ -11,14 +11,16 @@ export const PeriodUnitToNumber = {
|
|||
day: 1,
|
||||
week: 2,
|
||||
} as const;
|
||||
export type PeriodUnits = keyof typeof PeriodUnitToNumber;
|
||||
export type PeriodUnitNumbers = (typeof PeriodUnitToNumber)[PeriodUnits];
|
||||
|
||||
export type PeriodUnit = keyof typeof PeriodUnitToNumber;
|
||||
|
||||
export type PeriodUnitNumber = (typeof PeriodUnitToNumber)[PeriodUnit];
|
||||
export const NumberToPeriodUnit = Object.entries(PeriodUnitToNumber).reduce(
|
||||
(acc, [key, value]: [PeriodUnits, PeriodUnitNumbers]) => {
|
||||
(acc, [key, value]: [PeriodUnit, PeriodUnitNumber]) => {
|
||||
acc[value] = key;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<PeriodUnitNumbers, PeriodUnits>,
|
||||
{} as Record<PeriodUnitNumber, PeriodUnit>,
|
||||
);
|
||||
export function isValidPeriodNumber(value: number) {
|
||||
return isValid(value, NumberToPeriodUnit);
|
||||
|
|
@ -31,14 +33,16 @@ export const TypeToNumber = {
|
|||
success: 2,
|
||||
failure: 3,
|
||||
} as const;
|
||||
export type TypeUnits = keyof typeof TypeToNumber;
|
||||
export type TypeUnitNumbers = (typeof TypeToNumber)[TypeUnits];
|
||||
|
||||
export type TypeUnit = keyof typeof TypeToNumber;
|
||||
|
||||
export type TypeUnitNumber = (typeof TypeToNumber)[TypeUnit];
|
||||
export const NumberToType = Object.entries(TypeToNumber).reduce(
|
||||
(acc, [key, value]: [TypeUnits, TypeUnitNumbers]) => {
|
||||
(acc, [key, value]: [TypeUnit, TypeUnitNumber]) => {
|
||||
acc[value] = key;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<TypeUnitNumbers, TypeUnits>,
|
||||
{} as Record<TypeUnitNumber, TypeUnit>,
|
||||
);
|
||||
|
||||
export function isValidTypeNumber(value: number) {
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sql } from '@/utils/sql';
|
||||
|
||||
import { InsightsByPeriod } from '../entities/insights-by-period';
|
||||
import type { PeriodUnit } from '../entities/insights-shared';
|
||||
import { PeriodUnitToNumber } from '../entities/insights-shared';
|
||||
|
||||
const dbType = Container.get(GlobalConfig).database.type;
|
||||
|
||||
const summaryParser = z
|
||||
.object({
|
||||
period: z.enum(['previous', 'current']),
|
||||
type: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]),
|
||||
|
||||
// depending on db engine, sum(value) can be a number or a string - because of big numbers
|
||||
total_value: z.union([z.number(), z.string()]),
|
||||
})
|
||||
.array();
|
||||
|
||||
@Service()
|
||||
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(InsightsByPeriod, dataSource.manager);
|
||||
}
|
||||
|
||||
private escapeField(fieldName: string) {
|
||||
return this.manager.connection.driver.escape(fieldName);
|
||||
}
|
||||
|
||||
private getPeriodFilterExpr(periodUnit: PeriodUnit) {
|
||||
const daysAgo = periodUnit === 'day' ? 90 : 180;
|
||||
// Database-specific period start expression to filter out data to compact by days matching the periodUnit
|
||||
let periodStartExpr = `date('now', '-${daysAgo} days')`;
|
||||
if (dbType === 'postgresdb') {
|
||||
periodStartExpr = `CURRENT_DATE - INTERVAL '${daysAgo} day'`;
|
||||
} else if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||
periodStartExpr = `DATE_SUB(CURRENT_DATE, INTERVAL ${daysAgo} DAY)`;
|
||||
}
|
||||
|
||||
return periodStartExpr;
|
||||
}
|
||||
|
||||
private getPeriodStartExpr(periodUnit: PeriodUnit) {
|
||||
// Database-specific period start expression to truncate timestamp to the periodUnit
|
||||
// SQLite by default
|
||||
let periodStartExpr = `strftime('%Y-%m-%d ${periodUnit === 'hour' ? '%H' : '00'}:00:00.000', periodStart)`;
|
||||
if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||
periodStartExpr =
|
||||
periodUnit === 'hour'
|
||||
? "DATE_FORMAT(periodStart, '%Y-%m-%d %H:00:00')"
|
||||
: "DATE_FORMAT(periodStart, '%Y-%m-%d 00:00:00')";
|
||||
} else if (dbType === 'postgresdb') {
|
||||
periodStartExpr = `DATE_TRUNC('${periodUnit}', ${this.escapeField('periodStart')})`;
|
||||
}
|
||||
|
||||
return periodStartExpr;
|
||||
}
|
||||
|
||||
getPeriodInsightsBatchQuery(periodUnit: PeriodUnit, compactionBatchSize: number) {
|
||||
// Build the query to gather period insights data for the batch
|
||||
const batchQuery = this.createQueryBuilder()
|
||||
.select(
|
||||
['id', 'metaId', 'type', 'periodStart', 'value'].map((fieldName) =>
|
||||
this.escapeField(fieldName),
|
||||
),
|
||||
)
|
||||
.where(`${this.escapeField('periodUnit')} = ${PeriodUnitToNumber[periodUnit]}`)
|
||||
.andWhere(`${this.escapeField('periodStart')} < ${this.getPeriodFilterExpr('day')}`)
|
||||
.orderBy(this.escapeField('periodStart'), 'ASC')
|
||||
.limit(compactionBatchSize);
|
||||
return batchQuery;
|
||||
}
|
||||
|
||||
getAggregationQuery(periodUnit: PeriodUnit) {
|
||||
// Get the start period expression depending on the period unit and database type
|
||||
const periodStartExpr = this.getPeriodStartExpr(periodUnit);
|
||||
|
||||
// Function to get the aggregation query
|
||||
const aggregationQuery = this.manager
|
||||
.createQueryBuilder()
|
||||
.select(this.escapeField('metaId'))
|
||||
.addSelect(this.escapeField('type'))
|
||||
.addSelect(PeriodUnitToNumber[periodUnit].toString(), 'periodUnit')
|
||||
.addSelect(periodStartExpr, 'periodStart')
|
||||
.addSelect(`SUM(${this.escapeField('value')})`, 'value')
|
||||
.from('rows_to_compact', 'rtc')
|
||||
.groupBy(this.escapeField('metaId'))
|
||||
.addGroupBy(this.escapeField('type'))
|
||||
.addGroupBy(periodStartExpr);
|
||||
|
||||
return aggregationQuery;
|
||||
}
|
||||
|
||||
async compactSourceDataIntoInsightPeriod({
|
||||
sourceBatchQuery, // Query to get batch source data. Must return those fields: 'id', 'metaId', 'type', 'periodStart', 'value'
|
||||
sourceTableName = this.metadata.tableName, // Repository references for table operations
|
||||
periodUnit,
|
||||
}: {
|
||||
sourceBatchQuery: string;
|
||||
sourceTableName?: string;
|
||||
periodUnit: PeriodUnit;
|
||||
}): Promise<number> {
|
||||
// Create temp table that only exists in this transaction for rows to compact
|
||||
const getBatchAndStoreInTemporaryTable = sql`
|
||||
CREATE TEMPORARY TABLE rows_to_compact AS
|
||||
${sourceBatchQuery};
|
||||
`;
|
||||
|
||||
const countBatch = sql`
|
||||
SELECT COUNT(*) ${this.escapeField('rowsInBatch')} FROM rows_to_compact;
|
||||
`;
|
||||
|
||||
const targetColumnNamesStr = ['metaId', 'type', 'periodUnit', 'periodStart']
|
||||
.map((param) => this.escapeField(param))
|
||||
.join(', ');
|
||||
const targetColumnNamesWithValue = `${targetColumnNamesStr}, value`;
|
||||
|
||||
// Function to get the aggregation query
|
||||
const aggregationQuery = this.getAggregationQuery(periodUnit);
|
||||
|
||||
// Insert or update aggregated data
|
||||
const insertQueryBase = sql`
|
||||
INSERT INTO ${this.metadata.tableName}
|
||||
(${targetColumnNamesWithValue})
|
||||
${aggregationQuery.getSql()}
|
||||
`;
|
||||
|
||||
// Database-specific duplicate key logic
|
||||
let deduplicateQuery: string;
|
||||
if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||
deduplicateQuery = sql`
|
||||
ON DUPLICATE KEY UPDATE value = value + VALUES(value)`;
|
||||
} else {
|
||||
deduplicateQuery = sql`
|
||||
ON CONFLICT(${targetColumnNamesStr})
|
||||
DO UPDATE SET value = ${this.metadata.tableName}.value + excluded.value
|
||||
RETURNING *`;
|
||||
}
|
||||
|
||||
const upsertEvents = sql`
|
||||
${insertQueryBase}
|
||||
${deduplicateQuery}
|
||||
`;
|
||||
|
||||
// Delete the processed rows
|
||||
const deleteBatch = sql`
|
||||
DELETE FROM ${sourceTableName}
|
||||
WHERE id IN (SELECT id FROM rows_to_compact);
|
||||
`;
|
||||
|
||||
// Clean up
|
||||
const dropTemporaryTable = sql`
|
||||
DROP TABLE rows_to_compact;
|
||||
`;
|
||||
|
||||
const result = await this.manager.transaction(async (trx) => {
|
||||
await trx.query(getBatchAndStoreInTemporaryTable);
|
||||
|
||||
await trx.query<Array<{ type: any; value: number }>>(upsertEvents);
|
||||
|
||||
const rowsInBatch = await trx.query<[{ rowsInBatch: number | string }]>(countBatch);
|
||||
|
||||
await trx.query(deleteBatch);
|
||||
await trx.query(dropTemporaryTable);
|
||||
|
||||
return Number(rowsInBatch[0].rowsInBatch);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPreviousAndCurrentPeriodTypeAggregates(): Promise<
|
||||
Array<{
|
||||
period: 'previous' | 'current';
|
||||
type: 0 | 1 | 2 | 3;
|
||||
total_value: string | number;
|
||||
}>
|
||||
> {
|
||||
const cte =
|
||||
dbType === 'sqlite'
|
||||
? sql`
|
||||
SELECT
|
||||
datetime('now', '-7 days') AS current_start,
|
||||
datetime('now') AS current_end,
|
||||
datetime('now', '-14 days') AS previous_start
|
||||
`
|
||||
: dbType === 'postgresdb'
|
||||
? sql`
|
||||
SELECT
|
||||
(CURRENT_DATE - INTERVAL '7 days')::timestamptz AS current_start,
|
||||
CURRENT_DATE::timestamptz AS current_end,
|
||||
(CURRENT_DATE - INTERVAL '14 days')::timestamptz AS previous_start
|
||||
`
|
||||
: sql`
|
||||
SELECT
|
||||
DATE_SUB(CURDATE(), INTERVAL 7 DAY) AS current_start,
|
||||
CURDATE() AS current_end,
|
||||
DATE_SUB(CURDATE(), INTERVAL 14 DAY) AS previous_start
|
||||
`;
|
||||
|
||||
const rawRows = await this.createQueryBuilder('insights')
|
||||
.addCommonTableExpression(cte, 'date_ranges')
|
||||
.select(
|
||||
sql`
|
||||
CASE
|
||||
WHEN insights.periodStart >= date_ranges.current_start AND insights.periodStart <= date_ranges.current_end
|
||||
THEN 'current'
|
||||
ELSE 'previous'
|
||||
END
|
||||
`,
|
||||
'period',
|
||||
)
|
||||
.addSelect('insights.type', 'type')
|
||||
.addSelect('SUM(value)', 'total_value')
|
||||
// Use a cross join with the CTE
|
||||
.innerJoin('date_ranges', 'date_ranges', '1=1')
|
||||
// Filter to only include data from the last 14 days
|
||||
.where('insights.periodStart >= date_ranges.previous_start')
|
||||
.andWhere('insights.periodStart <= date_ranges.current_end')
|
||||
// Group by both period and type
|
||||
.groupBy('period')
|
||||
.addGroupBy('insights.type')
|
||||
.getRawMany();
|
||||
|
||||
return summaryParser.parse(rawRows);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { InsightsRaw } from '../entities/insights-raw';
|
||||
|
||||
@Service()
|
||||
export class InsightsRawRepository extends Repository<InsightsRaw> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(InsightsRaw, dataSource.manager);
|
||||
}
|
||||
|
||||
getRawInsightsBatchQuery(compactionBatchSize: number) {
|
||||
// Build the query to gather raw insights data for the batch
|
||||
const batchQuery = this.createQueryBuilder()
|
||||
.select(
|
||||
['id', 'metaId', 'type', 'value'].map((fieldName) =>
|
||||
this.manager.connection.driver.escape(fieldName),
|
||||
),
|
||||
)
|
||||
.addSelect('timestamp', 'periodStart')
|
||||
.orderBy('timestamp', 'ASC')
|
||||
.limit(compactionBatchSize);
|
||||
return batchQuery;
|
||||
}
|
||||
}
|
||||
18
packages/cli/src/modules/insights/insights.config.ts
Normal file
18
packages/cli/src/modules/insights/insights.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Config, Env } from '@n8n/config';
|
||||
|
||||
@Config
|
||||
export class InsightsConfig {
|
||||
/**
|
||||
* The interval in minutes at which the insights data should be compacted.
|
||||
* Default: 60
|
||||
*/
|
||||
@Env('N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES')
|
||||
compactionIntervalMinutes: number = 60;
|
||||
|
||||
/**
|
||||
* The number of raw insights data to compact in a single batch.
|
||||
* Default: 500
|
||||
*/
|
||||
@Env('N8N_INSIGHTS_COMPACTION_BATCH_SIZE')
|
||||
compactionBatchSize: number = 500;
|
||||
}
|
||||
16
packages/cli/src/modules/insights/insights.controller.ts
Normal file
16
packages/cli/src/modules/insights/insights.controller.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
|
||||
import { Get, GlobalScope, RestController } from '@/decorators';
|
||||
|
||||
import { InsightsService } from './insights.service';
|
||||
|
||||
@RestController('/insights')
|
||||
export class InsightsController {
|
||||
constructor(private readonly insightsService: InsightsService) {}
|
||||
|
||||
@Get('/summary')
|
||||
@GlobalScope('insights:list')
|
||||
async getInsightsSummary(): Promise<InsightsSummary> {
|
||||
return await this.insightsService.getInsightsSummary();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { N8nModule } from '@/decorators/module';
|
|||
|
||||
import { InsightsService } from './insights.service';
|
||||
|
||||
import './insights.controller';
|
||||
|
||||
@N8nModule()
|
||||
export class InsightsModule implements BaseN8nModule {
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,26 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import type { ExecutionLifecycleHooks } from 'n8n-core';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
||||
import {
|
||||
UnexpectedError,
|
||||
type ExecutionStatus,
|
||||
type IRun,
|
||||
type WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||
import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata';
|
||||
import { InsightsRaw } from '@/modules/insights/entities/insights-raw';
|
||||
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||
import { InsightsMetadata } from '@/modules/insights/database/entities/insights-metadata';
|
||||
import { InsightsRaw } from '@/modules/insights/database/entities/insights-raw';
|
||||
|
||||
import type { TypeUnit } from './database/entities/insights-shared';
|
||||
import { NumberToType } from './database/entities/insights-shared';
|
||||
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
|
||||
import { InsightsRawRepository } from './database/repositories/insights-raw.repository';
|
||||
import { InsightsConfig } from './insights.config';
|
||||
|
||||
const config = Container.get(InsightsConfig);
|
||||
|
||||
const shouldSkipStatus: Record<ExecutionStatus, boolean> = {
|
||||
success: false,
|
||||
|
|
@ -35,7 +49,34 @@ const shouldSkipMode: Record<WorkflowExecuteMode, boolean> = {
|
|||
|
||||
@Service()
|
||||
export class InsightsService {
|
||||
constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {}
|
||||
private compactInsightsTimer: NodeJS.Timer | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
|
||||
private readonly insightsRawRepository: InsightsRawRepository,
|
||||
) {
|
||||
this.initializeCompaction();
|
||||
}
|
||||
|
||||
initializeCompaction() {
|
||||
if (this.compactInsightsTimer !== undefined) {
|
||||
clearInterval(this.compactInsightsTimer);
|
||||
}
|
||||
const intervalMilliseconds = config.compactionIntervalMinutes * 60 * 1000;
|
||||
this.compactInsightsTimer = setInterval(
|
||||
async () => await this.compactInsights(),
|
||||
intervalMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
@OnShutdown()
|
||||
shutdown() {
|
||||
if (this.compactInsightsTimer !== undefined) {
|
||||
clearInterval(this.compactInsightsTimer);
|
||||
this.compactInsightsTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) {
|
||||
if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) {
|
||||
|
|
@ -107,4 +148,128 @@ export class InsightsService {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
async compactInsights() {
|
||||
let numberOfCompactedRawData: number;
|
||||
|
||||
// Compact raw data to hourly aggregates
|
||||
do {
|
||||
numberOfCompactedRawData = await this.compactRawToHour();
|
||||
} while (numberOfCompactedRawData > 0);
|
||||
|
||||
let numberOfCompactedHourData: number;
|
||||
|
||||
// Compact hourly data to daily aggregates
|
||||
do {
|
||||
numberOfCompactedHourData = await this.compactHourToDay();
|
||||
} while (numberOfCompactedHourData > 0);
|
||||
}
|
||||
|
||||
// Compacts raw data to hourly aggregates
|
||||
async compactRawToHour() {
|
||||
// Build the query to gather raw insights data for the batch
|
||||
const batchQuery = this.insightsRawRepository.getRawInsightsBatchQuery(
|
||||
config.compactionBatchSize,
|
||||
);
|
||||
|
||||
return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({
|
||||
sourceBatchQuery: batchQuery.getSql(),
|
||||
sourceTableName: this.insightsRawRepository.metadata.tableName,
|
||||
periodUnit: 'hour',
|
||||
});
|
||||
}
|
||||
|
||||
// Compacts hourly data to daily aggregates
|
||||
async compactHourToDay() {
|
||||
// get hour data query for batching
|
||||
const batchQuery = this.insightsByPeriodRepository.getPeriodInsightsBatchQuery(
|
||||
'hour',
|
||||
config.compactionBatchSize,
|
||||
);
|
||||
|
||||
return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({
|
||||
sourceBatchQuery: batchQuery.getSql(),
|
||||
periodUnit: 'day',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: add return type once rebased on master and InsightsSummary is
|
||||
// available
|
||||
async getInsightsSummary(): Promise<InsightsSummary> {
|
||||
const rows = await this.insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates();
|
||||
|
||||
// Initialize data structures for both periods
|
||||
const data = {
|
||||
current: { byType: {} as Record<TypeUnit, number> },
|
||||
previous: { byType: {} as Record<TypeUnit, number> },
|
||||
};
|
||||
|
||||
// Organize data by period and type
|
||||
rows.forEach((row) => {
|
||||
const { period, type, total_value } = row;
|
||||
if (!data[period]) return;
|
||||
|
||||
data[period].byType[NumberToType[type]] = total_value ? Number(total_value) : 0;
|
||||
});
|
||||
|
||||
// Get values with defaults for missing data
|
||||
const getValueByType = (period: 'current' | 'previous', type: TypeUnit) =>
|
||||
data[period]?.byType[type] ?? 0;
|
||||
|
||||
// Calculate metrics
|
||||
const currentSuccesses = getValueByType('current', 'success');
|
||||
const currentFailures = getValueByType('current', 'failure');
|
||||
const previousSuccesses = getValueByType('previous', 'success');
|
||||
const previousFailures = getValueByType('previous', 'failure');
|
||||
|
||||
const currentTotal = currentSuccesses + currentFailures;
|
||||
const previousTotal = previousSuccesses + previousFailures;
|
||||
|
||||
const currentFailureRate =
|
||||
currentTotal > 0 ? Math.round((currentFailures / currentTotal) * 100) / 100 : 0;
|
||||
const previousFailureRate =
|
||||
previousTotal > 0 ? Math.round((previousFailures / previousTotal) * 100) / 100 : 0;
|
||||
|
||||
const currentTotalRuntime = getValueByType('current', 'runtime_ms') ?? 0;
|
||||
const previousTotalRuntime = getValueByType('previous', 'runtime_ms') ?? 0;
|
||||
|
||||
const currentAvgRuntime =
|
||||
currentTotal > 0 ? Math.round((currentTotalRuntime / currentTotal) * 100) / 100 : 0;
|
||||
const previousAvgRuntime =
|
||||
previousTotal > 0 ? Math.round((previousTotalRuntime / previousTotal) * 100) / 100 : 0;
|
||||
|
||||
const currentTimeSaved = getValueByType('current', 'time_saved_min');
|
||||
const previousTimeSaved = getValueByType('previous', 'time_saved_min');
|
||||
|
||||
// Return the formatted result
|
||||
const result: InsightsSummary = {
|
||||
averageRunTime: {
|
||||
value: currentAvgRuntime,
|
||||
unit: 'time',
|
||||
deviation: currentAvgRuntime - previousAvgRuntime,
|
||||
},
|
||||
failed: {
|
||||
value: currentFailures,
|
||||
unit: 'count',
|
||||
deviation: currentFailures - previousFailures,
|
||||
},
|
||||
failureRate: {
|
||||
value: currentFailureRate,
|
||||
unit: 'ratio',
|
||||
deviation: currentFailureRate - previousFailureRate,
|
||||
},
|
||||
timeSaved: {
|
||||
value: currentTimeSaved,
|
||||
unit: 'time',
|
||||
deviation: currentTimeSaved - previousTimeSaved,
|
||||
},
|
||||
total: {
|
||||
value: currentTotal,
|
||||
unit: 'count',
|
||||
deviation: currentTotal - previousTotal,
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { InsightsByPeriod } from '../entities/insights-by-period';
|
||||
|
||||
@Service()
|
||||
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(InsightsByPeriod, dataSource.manager);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { InsightsRaw } from '../entities/insights-raw';
|
||||
|
||||
@Service()
|
||||
export class InsightsRawRepository extends Repository<InsightsRaw> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(InsightsRaw, dataSource.manager);
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'project:read',
|
||||
'project:update',
|
||||
'project:delete',
|
||||
'insights:list',
|
||||
];
|
||||
|
||||
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ import '@/executions/executions.controller';
|
|||
import '@/external-secrets.ee/external-secrets.controller.ee';
|
||||
import '@/license/license.controller';
|
||||
import '@/evaluation.ee/test-definitions.controller.ee';
|
||||
import '@/evaluation.ee/metrics.controller';
|
||||
import '@/evaluation.ee/test-runs.controller.ee';
|
||||
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
|
||||
import '@/workflows/workflows.controller';
|
||||
|
|
|
|||
|
|
@ -1,381 +0,0 @@
|
|||
import { Container } from '@n8n/di';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
||||
import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
|
||||
import { createUserShell } from '@test-integration/db/users';
|
||||
import { createWorkflow } from '@test-integration/db/workflows';
|
||||
import * as testDb from '@test-integration/test-db';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import * as utils from '@test-integration/utils';
|
||||
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
let workflowUnderTest: IWorkflowBase;
|
||||
let otherWorkflow: IWorkflowBase;
|
||||
let testDefinition: TestDefinition;
|
||||
let otherTestDefinition: TestDefinition;
|
||||
let ownerShell: User;
|
||||
|
||||
const testServer = utils.setupTestServer({ endpointGroups: ['evaluation'] });
|
||||
|
||||
beforeAll(async () => {
|
||||
ownerShell = await createUserShell('global:owner');
|
||||
authOwnerAgent = testServer.authAgentFor(ownerShell);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['TestDefinition', 'TestMetric']);
|
||||
|
||||
workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell);
|
||||
|
||||
testDefinition = Container.get(TestDefinitionRepository).create({
|
||||
name: 'test',
|
||||
workflow: { id: workflowUnderTest.id },
|
||||
});
|
||||
await Container.get(TestDefinitionRepository).save(testDefinition);
|
||||
|
||||
otherWorkflow = await createWorkflow({ name: 'other-workflow' });
|
||||
|
||||
otherTestDefinition = Container.get(TestDefinitionRepository).create({
|
||||
name: 'other-test',
|
||||
workflow: { id: otherWorkflow.id },
|
||||
});
|
||||
await Container.get(TestDefinitionRepository).save(otherTestDefinition);
|
||||
});
|
||||
|
||||
describe('GET /evaluation/test-definitions/:testDefinitionId/metrics', () => {
|
||||
test('should retrieve empty list of metrics for a test definition', async () => {
|
||||
const resp = await authOwnerAgent.get(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/metrics`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should retrieve metrics for a test definition', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const newMetric2 = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-2',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric2);
|
||||
|
||||
const resp = await authOwnerAgent.get(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/metrics`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data.length).toBe(2);
|
||||
expect(resp.body.data).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
name: 'metric-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
name: 'metric-2',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 404 if test definition does not exist', async () => {
|
||||
const resp = await authOwnerAgent.get('/evaluation/test-definitions/999/metrics');
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 if test definition is not accessible to the user', async () => {
|
||||
const resp = await authOwnerAgent.get(
|
||||
`/evaluation/test-definitions/${otherTestDefinition.id}/metrics`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => {
|
||||
test('should retrieve a metric for a test definition', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent.get(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data).toEqual(
|
||||
expect.objectContaining({
|
||||
id: newMetric.id,
|
||||
name: 'metric-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 404 if metric does not exist', async () => {
|
||||
const resp = await authOwnerAgent.get(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/metrics/999`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 if metric is not accessible to the user', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: otherTestDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent.get(
|
||||
`/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /evaluation/test-definitions/:testDefinitionId/metrics', () => {
|
||||
test('should create a metric for a test definition', async () => {
|
||||
const resp = await authOwnerAgent
|
||||
.post(`/evaluation/test-definitions/${testDefinition.id}/metrics`)
|
||||
.send({
|
||||
name: 'metric-1',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
name: 'metric-1',
|
||||
}),
|
||||
);
|
||||
|
||||
const metrics = await Container.get(TestMetricRepository).find({
|
||||
where: { testDefinition: { id: testDefinition.id } },
|
||||
});
|
||||
expect(metrics.length).toBe(1);
|
||||
expect(metrics[0].name).toBe('metric-1');
|
||||
});
|
||||
|
||||
test('should return 400 if name is missing', async () => {
|
||||
const resp = await authOwnerAgent
|
||||
.post(`/evaluation/test-definitions/${testDefinition.id}/metrics`)
|
||||
.send({});
|
||||
|
||||
expect(resp.statusCode).toBe(400);
|
||||
expect(resp.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid_type',
|
||||
message: 'Required',
|
||||
path: ['name'],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 400 if name is not a string', async () => {
|
||||
const resp = await authOwnerAgent
|
||||
.post(`/evaluation/test-definitions/${testDefinition.id}/metrics`)
|
||||
.send({
|
||||
name: 123,
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(400);
|
||||
expect(resp.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid_type',
|
||||
message: 'Expected string, received number',
|
||||
path: ['name'],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 404 if test definition does not exist', async () => {
|
||||
const resp = await authOwnerAgent.post('/evaluation/test-definitions/999/metrics').send({
|
||||
name: 'metric-1',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 if test definition is not accessible to the user', async () => {
|
||||
const resp = await authOwnerAgent
|
||||
.post(`/evaluation/test-definitions/${otherTestDefinition.id}/metrics`)
|
||||
.send({
|
||||
name: 'metric-1',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => {
|
||||
test('should update a metric for a test definition', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent
|
||||
.patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`)
|
||||
.send({
|
||||
name: 'metric-2',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data).toEqual(
|
||||
expect.objectContaining({
|
||||
id: newMetric.id,
|
||||
name: 'metric-2',
|
||||
}),
|
||||
);
|
||||
|
||||
const metrics = await Container.get(TestMetricRepository).find({
|
||||
where: { testDefinition: { id: testDefinition.id } },
|
||||
});
|
||||
expect(metrics.length).toBe(1);
|
||||
expect(metrics[0].name).toBe('metric-2');
|
||||
});
|
||||
|
||||
test('should return 400 if name is missing', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent
|
||||
.patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`)
|
||||
.send({});
|
||||
|
||||
expect(resp.statusCode).toBe(400);
|
||||
expect(resp.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid_type',
|
||||
message: 'Required',
|
||||
path: ['name'],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 400 if name is not a string', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent
|
||||
.patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`)
|
||||
.send({
|
||||
name: 123,
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(400);
|
||||
expect(resp.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'invalid_type',
|
||||
message: 'Expected string, received number',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 404 if metric does not exist', async () => {
|
||||
const resp = await authOwnerAgent
|
||||
.patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/999`)
|
||||
.send({
|
||||
name: 'metric-1',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 if test definition does not exist', async () => {
|
||||
const resp = await authOwnerAgent.patch('/evaluation/test-definitions/999/metrics/999').send({
|
||||
name: 'metric-1',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 if metric is not accessible to the user', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: otherTestDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent
|
||||
.patch(`/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`)
|
||||
.send({
|
||||
name: 'metric-2',
|
||||
});
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => {
|
||||
test('should delete a metric for a test definition', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: testDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent.delete(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body.data).toEqual({ success: true });
|
||||
|
||||
const metrics = await Container.get(TestMetricRepository).find({
|
||||
where: { testDefinition: { id: testDefinition.id } },
|
||||
});
|
||||
expect(metrics.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should return 404 if metric does not exist', async () => {
|
||||
const resp = await authOwnerAgent.delete(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/metrics/999`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 if metric is not accessible to the user', async () => {
|
||||
const newMetric = Container.get(TestMetricRepository).create({
|
||||
testDefinition: { id: otherTestDefinition.id },
|
||||
name: 'metric-1',
|
||||
});
|
||||
await Container.get(TestMetricRepository).save(newMetric);
|
||||
|
||||
const resp = await authOwnerAgent.delete(
|
||||
`/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`,
|
||||
);
|
||||
|
||||
expect(resp.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -281,7 +281,6 @@ export const setupTestServer = ({
|
|||
break;
|
||||
|
||||
case 'evaluation':
|
||||
await import('@/evaluation.ee/metrics.controller');
|
||||
await import('@/evaluation.ee/test-definitions.controller.ee');
|
||||
await import('@/evaluation.ee/test-runs.controller.ee');
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "1.83.0",
|
||||
"version": "1.84.0",
|
||||
"description": "Core functionality of n8n",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat",
|
||||
"version": "0.36.0",
|
||||
"version": "0.37.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm run storybook",
|
||||
"build": "pnpm build:vite && pnpm build:bundle",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/design-system",
|
||||
"version": "1.72.0",
|
||||
"version": "1.73.0",
|
||||
"main": "src/index.ts",
|
||||
"import": "src/index.ts",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "1.84.0",
|
||||
"version": "1.85.0",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export function createCanvasNodeData({
|
|||
|
||||
export function createCanvasNodeElement({
|
||||
id = '1',
|
||||
type = 'default',
|
||||
type = 'canvas-node',
|
||||
label = 'Node',
|
||||
position = { x: 100, y: 100 },
|
||||
data,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
NO_OP_NODE_TYPE,
|
||||
SET_NODE_TYPE,
|
||||
SIMULATE_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||
|
|
@ -50,6 +51,7 @@ export const mockNode = ({
|
|||
|
||||
export const mockNodeTypeDescription = ({
|
||||
name = SET_NODE_TYPE,
|
||||
icon = 'fa:pen',
|
||||
version = 1,
|
||||
credentials = [],
|
||||
inputs = [NodeConnectionTypes.Main],
|
||||
|
|
@ -58,6 +60,7 @@ export const mockNodeTypeDescription = ({
|
|||
properties = [],
|
||||
}: {
|
||||
name?: INodeTypeDescription['name'];
|
||||
icon?: INodeTypeDescription['icon'];
|
||||
version?: INodeTypeDescription['version'];
|
||||
credentials?: INodeTypeDescription['credentials'];
|
||||
inputs?: INodeTypeDescription['inputs'];
|
||||
|
|
@ -67,6 +70,7 @@ export const mockNodeTypeDescription = ({
|
|||
} = {}) =>
|
||||
mock<INodeTypeDescription>({
|
||||
name,
|
||||
icon,
|
||||
displayName: name,
|
||||
description: '',
|
||||
version,
|
||||
|
|
@ -82,6 +86,7 @@ export const mockNodeTypeDescription = ({
|
|||
codex,
|
||||
credentials,
|
||||
documentationUrl: 'https://docs',
|
||||
iconUrl: 'nodes/test-node/icon.svg',
|
||||
webhooks: undefined,
|
||||
});
|
||||
|
||||
|
|
@ -101,6 +106,7 @@ export const mockNodes = [
|
|||
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
|
||||
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
|
||||
mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }),
|
||||
mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }),
|
||||
mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }),
|
||||
mockNode({ name: 'End', type: NO_OP_NODE_TYPE }),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -83,8 +83,6 @@ export interface TestCaseExecutionRecord {
|
|||
}
|
||||
|
||||
const endpoint = '/evaluation/test-definitions';
|
||||
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
|
||||
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
|
||||
|
||||
export async function getTestDefinitions(
|
||||
context: IRestApiContext,
|
||||
|
|
@ -141,86 +139,6 @@ export async function getExampleEvaluationInput(
|
|||
);
|
||||
}
|
||||
|
||||
// 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}` : ''}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface Props {
|
|||
modelValue: AssignmentValue;
|
||||
issues: string[];
|
||||
hideType?: boolean;
|
||||
disableType?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
|
@ -163,7 +164,7 @@ const onBlur = (): void => {
|
|||
<TypeSelect
|
||||
:class="$style.select"
|
||||
:model-value="assignment.type ?? 'string'"
|
||||
:is-read-only="isReadOnly"
|
||||
:is-read-only="disableType || isReadOnly"
|
||||
@update:model-value="onAssignmentTypeChange"
|
||||
>
|
||||
</TypeSelect>
|
||||
|
|
|
|||
|
|
@ -151,4 +151,69 @@ describe('AssignmentCollection.vue', () => {
|
|||
expect(getAssignmentType(assignments[3])).toEqual('Object');
|
||||
expect(getAssignmentType(assignments[4])).toEqual('Array');
|
||||
});
|
||||
|
||||
describe('defaultType prop', () => {
|
||||
it('should use string as default type when no defaultType is specified', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||
|
||||
const assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toBe(1);
|
||||
expect(getAssignmentType(assignments[0])).toEqual('String');
|
||||
});
|
||||
|
||||
it('should use specified defaultType when adding a new assignment manually', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent({
|
||||
props: {
|
||||
defaultType: 'number',
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||
|
||||
const assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toBe(1);
|
||||
expect(getAssignmentType(assignments[0])).toEqual('Number');
|
||||
});
|
||||
|
||||
it('should use defaultType for drag and drop when disableType is true', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent({
|
||||
props: {
|
||||
defaultType: 'number',
|
||||
disableType: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dropArea = getByTestId('drop-area');
|
||||
|
||||
// Even though we're dropping a string value, it should use number type because of defaultType
|
||||
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
|
||||
|
||||
const assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toBe(1);
|
||||
expect(getAssignmentType(assignments[0])).toEqual('Number');
|
||||
});
|
||||
|
||||
it('should respect defaultType for all assignments when provided', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent({
|
||||
props: {
|
||||
defaultType: 'boolean',
|
||||
},
|
||||
});
|
||||
|
||||
const dropArea = getByTestId('drop-area');
|
||||
|
||||
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||
|
||||
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
|
||||
await dropAssignment({ key: 'numberKey', value: 25, dropArea });
|
||||
|
||||
const assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toBe(3);
|
||||
expect(getAssignmentType(assignments[0])).toEqual('Boolean');
|
||||
expect(getAssignmentType(assignments[1])).toEqual('Boolean');
|
||||
expect(getAssignmentType(assignments[2])).toEqual('Boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||
import type {
|
||||
AssignmentCollectionValue,
|
||||
AssignmentValue,
|
||||
FieldTypeMap,
|
||||
INode,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
|
@ -20,11 +21,17 @@ interface Props {
|
|||
parameter: INodeProperties;
|
||||
value: AssignmentCollectionValue;
|
||||
path: string;
|
||||
defaultType?: keyof FieldTypeMap;
|
||||
disableType?: boolean;
|
||||
node: INode | null;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isReadOnly: false,
|
||||
defaultType: undefined,
|
||||
disableType: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
|
||||
|
|
@ -82,7 +89,7 @@ function addAssignment(): void {
|
|||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'string',
|
||||
type: props.defaultType ?? 'string',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +98,7 @@ function dropAssignment(expression: string): void {
|
|||
id: crypto.randomUUID(),
|
||||
name: propertyNameFromExpression(expression),
|
||||
value: `=${expression}`,
|
||||
type: typeFromExpression(expression),
|
||||
type: props.defaultType ?? typeFromExpression(expression),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +164,7 @@ function optionSelected(action: string) {
|
|||
:issues="getIssues(index)"
|
||||
:class="$style.assignment"
|
||||
:is-read-only="isReadOnly"
|
||||
:disable-type="disableType"
|
||||
@update:model-value="(value) => onAssignmentUpdate(index, value)"
|
||||
@remove="() => onAssignmentRemove(index)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ref } from 'vue';
|
|||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { Validatable, IValidator } from '@n8n/design-system';
|
||||
import { N8nFormInput } from '@n8n/design-system';
|
||||
import { VALID_EMAIL_REGEX } from '@/constants';
|
||||
import { VALID_EMAIL_REGEX, COMMUNITY_PLUS_DOCS_URL } from '@/constants';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
|
@ -86,9 +86,6 @@ const confirm = async () => {
|
|||
>
|
||||
<template #content>
|
||||
<div>
|
||||
<p :class="$style.top">
|
||||
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
|
||||
</p>
|
||||
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
|
||||
data?.customHeading ?? i18n.baseText('communityPlusModal.title')
|
||||
}}</N8nText>
|
||||
|
|
@ -141,6 +138,14 @@ const confirm = async () => {
|
|||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.notice">
|
||||
<N8nText size="xsmall" tag="span">
|
||||
{{ i18n.baseText('communityPlusModal.notice') }}
|
||||
<a :href="COMMUNITY_PLUS_DOCS_URL" target="_blank">
|
||||
{{ i18n.baseText('generic.moreInfo') }}
|
||||
</a>
|
||||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.buttons">
|
||||
<N8nButton :class="$style.skip" type="secondary" text @click="closeModal">{{
|
||||
i18n.baseText('communityPlusModal.button.skip')
|
||||
|
|
@ -154,10 +159,8 @@ const confirm = async () => {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 0 var(--spacing-s);
|
||||
.notice {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
|
||||
.features {
|
||||
|
|
|
|||
|
|
@ -111,14 +111,14 @@ const onClaimCreditsClicked = async () => {
|
|||
</template>
|
||||
</n8n-callout>
|
||||
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
|
||||
<n8n-text>
|
||||
<n8n-text size="small">
|
||||
{{
|
||||
i18n.baseText('freeAi.credits.callout.success.title.part1', {
|
||||
interpolate: { credits: settingsStore.aiCreditsQuota },
|
||||
})
|
||||
}}</n8n-text
|
||||
>
|
||||
<n8n-text :bold="true">
|
||||
<n8n-text size="small" bold="true">
|
||||
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
|
||||
>
|
||||
</n8n-callout>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ const connectedNodes = computed<
|
|||
).reverse(),
|
||||
[FloatingNodePosition.left]: getINodesFromNames(
|
||||
workflow.getParentNodes(rootName, NodeConnectionTypes.Main, 1),
|
||||
),
|
||||
).reverse(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
} from '@/constants';
|
||||
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
|
||||
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
|
||||
|
|
@ -29,8 +28,7 @@ import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
|||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||
import NoResults from '../Panel/NoResults.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { getNodeIconSource } from '@/utils/nodeIcon';
|
||||
import { useActions } from '../composables/useActions';
|
||||
import { SEND_AND_WAIT_OPERATION, type INodeParameters } from 'n8n-workflow';
|
||||
|
||||
|
|
@ -43,8 +41,6 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
|
||||
const { pushViewStack, popViewStack } = useViewStacks();
|
||||
|
|
@ -83,20 +79,16 @@ function onSelected(item: INodeCreateElement) {
|
|||
const infoKey = `nodeCreator.subcategoryInfos.${subcategoryKey}` as BaseTextKey;
|
||||
const info = i18n.baseText(infoKey);
|
||||
const extendedInfo = info !== infoKey ? { info } : {};
|
||||
const nodeIcon = item.properties.icon
|
||||
? ({ type: 'icon', name: item.properties.icon } as const)
|
||||
: undefined;
|
||||
|
||||
pushViewStack({
|
||||
subcategory: item.key,
|
||||
mode: 'nodes',
|
||||
title,
|
||||
nodeIcon,
|
||||
...extendedInfo,
|
||||
...(item.properties.icon
|
||||
? {
|
||||
nodeIcon: {
|
||||
icon: item.properties.icon,
|
||||
iconType: 'icon',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(item.properties.panelClass ? { panelClass: item.properties.panelClass } : {}),
|
||||
rootView: activeViewStack.value.rootView,
|
||||
forceIncludeNodes: item.properties.forceIncludeNodes,
|
||||
|
|
@ -130,11 +122,6 @@ function onSelected(item: INodeCreateElement) {
|
|||
return;
|
||||
}
|
||||
|
||||
const iconUrl = getNodeIconUrl(item.properties, uiStore.appliedTheme);
|
||||
const icon = iconUrl
|
||||
? rootStore.baseUrl + iconUrl
|
||||
: getNodeIcon(item.properties, uiStore.appliedTheme)?.split(':')[1];
|
||||
|
||||
const transformedActions = nodeActions?.map((a) =>
|
||||
transformNodeType(a, item.properties.displayName, 'action'),
|
||||
);
|
||||
|
|
@ -142,12 +129,7 @@ function onSelected(item: INodeCreateElement) {
|
|||
pushViewStack({
|
||||
subcategory: item.properties.displayName,
|
||||
title: item.properties.displayName,
|
||||
nodeIcon: {
|
||||
color: getNodeIconColor(item.properties),
|
||||
icon,
|
||||
iconType: iconUrl ? 'file' : 'icon',
|
||||
},
|
||||
|
||||
nodeIcon: getNodeIconSource(item.properties),
|
||||
rootView: activeViewStack.value.rootView,
|
||||
hasSearch: true,
|
||||
mode: 'actions',
|
||||
|
|
|
|||
|
|
@ -247,5 +247,21 @@ describe('NodesListPanel', () => {
|
|||
await nextTick();
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should trim search input before emitting update', async () => {
|
||||
renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
|
||||
await fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
|
||||
target: { value: ' Node 1' },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1);
|
||||
expect(screen.queryByText('Node 1')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('node-creator-search-bar')).toHaveValue('Node 1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import ActionsRenderer from '../Modes/ActionsMode.vue';
|
|||
import NodesRenderer from '../Modes/NodesMode.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const { callDebounced } = useDebounce();
|
||||
|
|
@ -155,13 +156,10 @@ function onBackButton() {
|
|||
>
|
||||
<font-awesome-icon :class="$style.backButtonIcon" icon="arrow-left" size="2x" />
|
||||
</button>
|
||||
<n8n-node-icon
|
||||
<NodeIcon
|
||||
v-if="activeViewStack.nodeIcon"
|
||||
:class="$style.nodeIcon"
|
||||
:type="activeViewStack.nodeIcon.iconType || 'unknown'"
|
||||
:src="activeViewStack.nodeIcon.icon"
|
||||
:name="activeViewStack.nodeIcon.icon"
|
||||
:color="activeViewStack.nodeIcon.color"
|
||||
:icon-source="activeViewStack.nodeIcon"
|
||||
:circle="false"
|
||||
:show-tooltip="false"
|
||||
:size="20"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function focus() {
|
|||
|
||||
function onInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
emit('update:modelValue', input.value);
|
||||
emit('update:modelValue', input.value.trim());
|
||||
}
|
||||
|
||||
function clear() {
|
||||
|
|
|
|||
|
|
@ -39,9 +39,12 @@ import { useKeyboardNavigation } from './useKeyboardNavigation';
|
|||
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { AI_TRANSFORM_NODE_TYPE } from 'n8n-workflow';
|
||||
import type { NodeConnectionType, INodeInputFilter, Themed } from 'n8n-workflow';
|
||||
import type { NodeConnectionType, INodeInputFilter } from 'n8n-workflow';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { type NodeIconSource } from '@/utils/nodeIcon';
|
||||
import { getThemedValue } from '@/utils/nodeTypesUtils';
|
||||
|
||||
interface ViewStack {
|
||||
uuid?: string;
|
||||
|
|
@ -50,12 +53,7 @@ interface ViewStack {
|
|||
search?: string;
|
||||
subcategory?: string;
|
||||
info?: string;
|
||||
nodeIcon?: {
|
||||
iconType?: string;
|
||||
icon?: Themed<string>;
|
||||
color?: string;
|
||||
};
|
||||
iconUrl?: string;
|
||||
nodeIcon?: NodeIconSource;
|
||||
rootView?: NodeFilterType;
|
||||
activeIndex?: number;
|
||||
transitionDirection?: 'in' | 'out';
|
||||
|
|
@ -314,6 +312,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
|
||||
await nextTick();
|
||||
|
||||
const iconName = getThemedValue(relatedAIView?.properties.icon, useUIStore().appliedTheme);
|
||||
pushViewStack(
|
||||
{
|
||||
title: relatedAIView?.properties.title,
|
||||
|
|
@ -321,11 +320,13 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
|
|||
rootView: AI_OTHERS_NODE_CREATOR_VIEW,
|
||||
mode: 'nodes',
|
||||
items: nodeCreatorStore.allNodeCreatorNodes,
|
||||
nodeIcon: {
|
||||
iconType: 'icon',
|
||||
icon: relatedAIView?.properties.icon,
|
||||
color: relatedAIView?.properties.iconProps?.color,
|
||||
},
|
||||
nodeIcon: iconName
|
||||
? {
|
||||
type: 'icon',
|
||||
name: iconName,
|
||||
color: relatedAIView?.properties.iconProps?.color,
|
||||
}
|
||||
: undefined,
|
||||
panelClass: relatedAIView?.properties.panelClass,
|
||||
baseFilter: (i: INodeCreateElement) => {
|
||||
// AI Code node could have any connection type so we don't want to display it
|
||||
|
|
|
|||
|
|
@ -1,24 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { IVersionNode, SimplifiedNodeType } from '@/Interface';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import {
|
||||
getBadgeIconUrl,
|
||||
getNodeIcon,
|
||||
getNodeIconColor,
|
||||
getNodeIconUrl,
|
||||
} from '@/utils/nodeTypesUtils';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { getNodeIconSource, type NodeIconSource } from '@/utils/nodeIcon';
|
||||
import { N8nNodeIcon } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface NodeIconSource {
|
||||
path?: string;
|
||||
fileBuffer?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
nodeType?: INodeTypeDescription | SimplifiedNodeType | IVersionNode | null;
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
circle?: boolean;
|
||||
|
|
@ -26,10 +12,15 @@ type Props = {
|
|||
showTooltip?: boolean;
|
||||
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
nodeName?: string;
|
||||
// NodeIcon needs iconSource OR nodeType, would be better with an intersection type
|
||||
// but it breaks Vue template type checking
|
||||
iconSource?: NodeIconSource;
|
||||
nodeType?: SimplifiedNodeType | IVersionNode | null;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
nodeType: undefined,
|
||||
iconSource: undefined,
|
||||
size: undefined,
|
||||
circle: false,
|
||||
disabled: false,
|
||||
|
|
@ -37,97 +28,57 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
tooltipPosition: 'top',
|
||||
colorDefault: '',
|
||||
nodeName: '',
|
||||
badgeIconUrl: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const iconType = computed(() => {
|
||||
const nodeType = props.nodeType;
|
||||
|
||||
if (nodeType) {
|
||||
if (nodeType.iconUrl) return 'file';
|
||||
if ('iconData' in nodeType && nodeType.iconData) {
|
||||
return nodeType.iconData.type;
|
||||
}
|
||||
if (nodeType.icon) {
|
||||
const icon = getNodeIcon(nodeType, uiStore.appliedTheme);
|
||||
return icon && icon.split(':')[0] === 'file' ? 'file' : 'icon';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
const iconSource = computed(() => {
|
||||
if (props.iconSource) return props.iconSource;
|
||||
return getNodeIconSource(props.nodeType);
|
||||
});
|
||||
|
||||
const color = computed(() => getNodeIconColor(props.nodeType) ?? props.colorDefault ?? '');
|
||||
const iconType = computed(() => iconSource.value?.type ?? 'unknown');
|
||||
const src = computed(() => {
|
||||
if (iconSource.value?.type !== 'file') return;
|
||||
return iconSource.value.src;
|
||||
});
|
||||
|
||||
const iconSource = computed<NodeIconSource>(() => {
|
||||
const nodeType = props.nodeType;
|
||||
const baseUrl = rootStore.baseUrl;
|
||||
const iconName = computed(() => {
|
||||
if (iconSource.value?.type !== 'icon') return;
|
||||
return iconSource.value.name;
|
||||
});
|
||||
|
||||
if (nodeType) {
|
||||
// If node type has icon data, use it
|
||||
if ('iconData' in nodeType && nodeType.iconData) {
|
||||
return {
|
||||
icon: nodeType.iconData.icon,
|
||||
fileBuffer: nodeType.iconData.fileBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
const iconUrl = getNodeIconUrl(nodeType, uiStore.appliedTheme);
|
||||
if (iconUrl) {
|
||||
return { path: baseUrl + iconUrl };
|
||||
}
|
||||
// Otherwise, extract it from icon prop
|
||||
if (nodeType.icon) {
|
||||
const icon = getNodeIcon(nodeType, uiStore.appliedTheme);
|
||||
|
||||
if (icon) {
|
||||
const [type, path] = icon.split(':');
|
||||
if (type === 'file') {
|
||||
throw new Error(`Unexpected icon: ${icon}`);
|
||||
}
|
||||
|
||||
return { icon: path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
const iconColor = computed(() => {
|
||||
if (iconSource.value?.type !== 'icon') return;
|
||||
return iconSource.value.color ?? props.colorDefault;
|
||||
});
|
||||
|
||||
const badge = computed(() => {
|
||||
const nodeType = props.nodeType;
|
||||
if (nodeType && 'badgeIconUrl' in nodeType && nodeType.badgeIconUrl) {
|
||||
return {
|
||||
type: 'file',
|
||||
src: rootStore.baseUrl + getBadgeIconUrl(nodeType, uiStore.appliedTheme),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (iconSource.value?.badge?.type !== 'file') return;
|
||||
return iconSource.value.badge;
|
||||
});
|
||||
|
||||
const nodeTypeName = computed(() => props.nodeName ?? props.nodeType?.displayName);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-node-icon
|
||||
<N8nNodeIcon
|
||||
:type="iconType"
|
||||
:src="iconSource.path || iconSource.fileBuffer"
|
||||
:name="iconSource.icon"
|
||||
:color="color"
|
||||
:src="src"
|
||||
:name="iconName"
|
||||
:color="iconColor"
|
||||
:disabled="disabled"
|
||||
:size="size"
|
||||
:circle="circle"
|
||||
:node-type-name="nodeName ? nodeName : nodeType?.displayName"
|
||||
:node-type-name="nodeTypeName"
|
||||
:show-tooltip="showTooltip"
|
||||
:tooltip-position="tooltipPosition"
|
||||
:badge="badge"
|
||||
@click="emit('click')"
|
||||
></n8n-node-icon>
|
||||
></N8nNodeIcon>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
|
|
|
|||
|
|
@ -665,6 +665,8 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
|
|||
:path="getPath(parameter.name)"
|
||||
:node="node"
|
||||
:is-read-only="isReadOnly"
|
||||
:default-type="parameter.typeOptions?.assignment?.defaultType"
|
||||
:disable-type="parameter.typeOptions?.assignment?.disableType"
|
||||
@value-changed="valueChanged"
|
||||
/>
|
||||
<div v-else-if="credentialsParameterIndex !== index" class="parameter-item">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { VIEWS } from '@/constants';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useOverview } from '@/composables/useOverview';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('vue-router', async () => {
|
||||
|
|
@ -30,6 +31,12 @@ vi.mock('vue-router', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useOverview', () => ({
|
||||
useOverview: vi.fn().mockReturnValue({
|
||||
isOverviewSubPage: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const projectTabsSpy = vi.fn().mockReturnValue({
|
||||
render: vi.fn(),
|
||||
});
|
||||
|
|
@ -45,6 +52,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
|
|||
let route: ReturnType<typeof router.useRoute>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let overview: ReturnType<typeof useOverview>;
|
||||
|
||||
describe('ProjectHeader', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -52,6 +60,7 @@ describe('ProjectHeader', () => {
|
|||
route = router.useRoute();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
overview = useOverview();
|
||||
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
settingsStore.settings.folders = { enabled: false };
|
||||
|
|
@ -61,6 +70,27 @@ describe('ProjectHeader', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not render title icon on overview page', async () => {
|
||||
vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
||||
const { container } = renderComponent();
|
||||
|
||||
expect(container.querySelector('.fa-home')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the correct icon', async () => {
|
||||
vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||
const { container, rerender } = renderComponent();
|
||||
|
||||
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||
await rerender({});
|
||||
expect(container.querySelector('.fa-user')).toBeVisible();
|
||||
|
||||
const projectName = 'My Project';
|
||||
projectsStore.currentProject = { name: projectName } as Project;
|
||||
await rerender({});
|
||||
expect(container.querySelector('.fa-layer-group')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the correct title and subtitle', async () => {
|
||||
const { getByText, queryByText, rerender } = renderComponent();
|
||||
const subtitle = 'All the workflows, credentials and executions you have access to';
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ import { useRoute, useRouter } from 'vue-router';
|
|||
import type { UserAction } from '@n8n/design-system';
|
||||
import { N8nButton, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { type ProjectIcon as ProjectIconType, ProjectTypes } from '@/types/projects.types';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import ProjectIcon from '@/components/Projects/ProjectIcon.vue';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useOverview } from '@/composables/useOverview';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
|
@ -19,11 +21,22 @@ const i18n = useI18n();
|
|||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const overview = useOverview();
|
||||
|
||||
const emit = defineEmits<{
|
||||
createFolder: [];
|
||||
}>();
|
||||
|
||||
const headerIcon = computed((): ProjectIconType => {
|
||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||
return { type: 'icon', value: 'user' };
|
||||
} else if (projectsStore.currentProject?.name) {
|
||||
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
|
||||
} else {
|
||||
return { type: 'icon', value: 'home' };
|
||||
}
|
||||
});
|
||||
|
||||
const projectName = computed(() => {
|
||||
if (!projectsStore.currentProject) {
|
||||
return i18n.baseText('projects.menu.overview');
|
||||
|
|
@ -48,7 +61,10 @@ const showSettings = computed(
|
|||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||
|
||||
const showFolders = computed(() => {
|
||||
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
|
||||
return (
|
||||
settingsStore.isFoldersFeatureEnabled &&
|
||||
[VIEWS.PROJECTS_WORKFLOWS, VIEWS.PROJECTS_FOLDERS].includes(route.name as VIEWS)
|
||||
);
|
||||
});
|
||||
|
||||
const ACTION_TYPES = {
|
||||
|
|
@ -126,6 +142,12 @@ const onSelect = (action: string) => {
|
|||
<div>
|
||||
<div :class="$style.projectHeader">
|
||||
<div :class="$style.projectDetails">
|
||||
<ProjectIcon
|
||||
v-if="!overview.isOverviewSubPage"
|
||||
:icon="headerIcon"
|
||||
:border-less="true"
|
||||
size="medium"
|
||||
/>
|
||||
<div :class="$style.headerActions">
|
||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
||||
<N8nText color="text-light">
|
||||
|
|
@ -168,7 +190,7 @@ const onSelect = (action: string) => {
|
|||
.projectHeader,
|
||||
.projectDescription {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--spacing-m);
|
||||
min-height: var(--spacing-3xl);
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useTemplateRef, nextTick } from 'vue';
|
||||
import type { TestMetricRecord } from '@/api/testDefinition.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nInput, N8nButton, N8nIconButton } from '@n8n/design-system';
|
||||
|
||||
export interface MetricsInputProps {
|
||||
modelValue: Array<Partial<TestMetricRecord>>;
|
||||
}
|
||||
const props = defineProps<MetricsInputProps>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: MetricsInputProps['modelValue']];
|
||||
deleteMetric: [metric: TestMetricRecord];
|
||||
}>();
|
||||
const locale = useI18n();
|
||||
const metricsRefs = useTemplateRef<Array<InstanceType<typeof N8nInput>>>('metric');
|
||||
|
||||
function addNewMetric() {
|
||||
emit('update:modelValue', [...props.modelValue, { name: '' }]);
|
||||
void nextTick(() => metricsRefs.value?.at(-1)?.focus());
|
||||
}
|
||||
|
||||
function updateMetric(index: number, name: string) {
|
||||
const newMetrics = [...props.modelValue];
|
||||
newMetrics[index].name = name;
|
||||
emit('update:modelValue', newMetrics);
|
||||
}
|
||||
|
||||
function onDeleteMetric(metric: Partial<TestMetricRecord>, index: number) {
|
||||
if (!metric.id) {
|
||||
const newMetrics = [...props.modelValue];
|
||||
newMetrics.splice(index, 1);
|
||||
emit('update:modelValue', newMetrics);
|
||||
} else {
|
||||
emit('deleteMetric', metric as TestMetricRecord);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="(metric, index) in modelValue"
|
||||
:key="index"
|
||||
:class="$style.metricItem"
|
||||
class="mb-xs"
|
||||
>
|
||||
<N8nInput
|
||||
ref="metric"
|
||||
data-test-id="evaluation-metric-item"
|
||||
:model-value="metric.name"
|
||||
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
|
||||
@update:model-value="(value: string) => updateMetric(index, value)"
|
||||
/>
|
||||
<N8nIconButton icon="trash" type="secondary" text @click="onDeleteMetric(metric, index)" />
|
||||
</div>
|
||||
<N8nButton
|
||||
type="secondary"
|
||||
:label="locale.baseText('testDefinition.edit.metricsNew')"
|
||||
@click="addNewMetric"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.metricItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { TestMetricRecord } from '@/api/testDefinition.ee';
|
||||
import BlockArrow from '@/components/TestDefinition/EditDefinition/BlockArrow.vue';
|
||||
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
|
||||
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
|
||||
import NodesPinning from '@/components/TestDefinition/EditDefinition/NodesPinning.vue';
|
||||
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
|
||||
import type { EditableFormState, EvaluationFormState } from '@/components/TestDefinition/types';
|
||||
|
|
@ -27,7 +25,6 @@ const props = defineProps<{
|
|||
}>();
|
||||
const emit = defineEmits<{
|
||||
openPinningModal: [];
|
||||
deleteMetric: [metric: TestMetricRecord];
|
||||
openExecutionsViewForTag: [];
|
||||
renameTag: [tag: string];
|
||||
evaluationWorkflowCreated: [workflowId: string];
|
||||
|
|
@ -64,7 +61,6 @@ const evaluationWorkflow = defineModel<EvaluationFormState['evaluationWorkflow']
|
|||
'evaluationWorkflow',
|
||||
{ required: true },
|
||||
);
|
||||
const metrics = defineModel<EvaluationFormState['metrics']>('metrics', { required: true });
|
||||
const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes', {
|
||||
required: true,
|
||||
});
|
||||
|
|
@ -177,25 +173,6 @@ function openExecutionsView() {
|
|||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<BlockArrow class="mt-5xs mb-5xs" />
|
||||
<!-- Metrics -->
|
||||
<EvaluationStep
|
||||
:title="locale.baseText('testDefinition.edit.step.metrics')"
|
||||
:issues="getFieldIssues('metrics')"
|
||||
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.metrics.tooltip')"
|
||||
:external-tooltip="!hasRuns"
|
||||
>
|
||||
<template #cardContent>
|
||||
<MetricsInput
|
||||
v-model="metrics"
|
||||
:class="{ 'has-issues': getFieldIssues('metrics').length > 0 }"
|
||||
class="mt-xs"
|
||||
@delete-metric="(metric) => emit('deleteMetric', metric)"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
</div>
|
||||
<Modal
|
||||
width="calc(100% - (48px * 2))"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export function useTestDefinitionForm() {
|
|||
value: '',
|
||||
__rl: true,
|
||||
},
|
||||
metrics: [],
|
||||
mockedNodes: [],
|
||||
});
|
||||
|
||||
|
|
@ -62,8 +61,6 @@ export function useTestDefinitionForm() {
|
|||
const testDefinition = evaluationsStore.testDefinitionsById[testId];
|
||||
|
||||
if (testDefinition) {
|
||||
const metrics = await evaluationsStore.fetchMetrics(testId);
|
||||
|
||||
state.value.description = {
|
||||
value: testDefinition.description ?? '',
|
||||
isEditing: false,
|
||||
|
|
@ -84,7 +81,6 @@ export function useTestDefinitionForm() {
|
|||
value: testDefinition.evaluationWorkflowId ?? '',
|
||||
__rl: true,
|
||||
};
|
||||
state.value.metrics = metrics;
|
||||
state.value.mockedNodes = testDefinition.mockedNodes ?? [];
|
||||
evaluationsStore.updateRunFieldIssues(testDefinition.id);
|
||||
}
|
||||
|
|
@ -110,37 +106,6 @@ export function useTestDefinitionForm() {
|
|||
}
|
||||
};
|
||||
|
||||
const deleteMetric = async (metricId: string, testId: string) => {
|
||||
await evaluationsStore.deleteMetric({ id: metricId, testDefinitionId: testId });
|
||||
state.value.metrics = state.value.metrics.filter((metric) => metric.id !== metricId);
|
||||
};
|
||||
|
||||
/**
|
||||
* This method would perform unnecessary updates on the BE
|
||||
* it's a performance degradation candidate if metrics reach certain amount
|
||||
*/
|
||||
const updateMetrics = async (testId: string) => {
|
||||
const promises = state.value.metrics.map(async (metric) => {
|
||||
if (!metric.name) return;
|
||||
if (!metric.id) {
|
||||
const createdMetric = await evaluationsStore.createMetric({
|
||||
name: metric.name,
|
||||
testDefinitionId: testId,
|
||||
});
|
||||
metric.id = createdMetric.id;
|
||||
} else {
|
||||
await evaluationsStore.updateMetric({
|
||||
name: metric.name,
|
||||
id: metric.id,
|
||||
testDefinitionId: testId,
|
||||
});
|
||||
}
|
||||
});
|
||||
isSaving.value = true;
|
||||
await Promise.all(promises);
|
||||
isSaving.value = false;
|
||||
};
|
||||
|
||||
const updateTest = async (testId: string) => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
|
|
@ -230,8 +195,6 @@ export function useTestDefinitionForm() {
|
|||
state,
|
||||
fields,
|
||||
isSaving: computed(() => isSaving.value),
|
||||
deleteMetric,
|
||||
updateMetrics,
|
||||
loadTestData,
|
||||
createTest,
|
||||
updateTest,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ const errorTooltipMap: Record<string, BaseTextKey> = {
|
|||
FAILED_TO_EXECUTE_EVALUATION_WORKFLOW: 'testDefinition.runDetail.error.evaluationFailed',
|
||||
FAILED_TO_EXECUTE_WORKFLOW: 'testDefinition.runDetail.error.executionFailed',
|
||||
TRIGGER_NO_LONGER_EXISTS: 'testDefinition.runDetail.error.triggerNoLongerExists',
|
||||
METRICS_MISSING: 'testDefinition.runDetail.error.metricsMissing',
|
||||
UNKNOWN_METRICS: 'testDefinition.runDetail.error.unknownMetrics',
|
||||
INVALID_METRICS: 'testDefinition.runDetail.error.invalidMetrics',
|
||||
|
||||
// Test run errors
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import MetricsInput from '../EditDefinition/MetricsInput.vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const renderComponent = createComponentRenderer(MetricsInput);
|
||||
|
||||
describe('MetricsInput', () => {
|
||||
let props: { modelValue: Array<{ id?: string; name: string }> };
|
||||
|
||||
beforeEach(() => {
|
||||
props = {
|
||||
modelValue: [
|
||||
{ name: 'Metric 1', id: 'metric-1' },
|
||||
{ name: 'Metric 2', id: 'metric-2' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it('should render correctly with initial metrics', () => {
|
||||
const { getAllByPlaceholderText } = renderComponent({ props });
|
||||
const inputs = getAllByPlaceholderText('e.g. latency');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toHaveValue('Metric 1');
|
||||
expect(inputs[1]).toHaveValue('Metric 2');
|
||||
});
|
||||
|
||||
it('should update a metric when typing in the input', async () => {
|
||||
const { getAllByPlaceholderText, emitted } = renderComponent({
|
||||
props: {
|
||||
modelValue: [{ name: '' }],
|
||||
},
|
||||
});
|
||||
const inputs = getAllByPlaceholderText('e.g. latency');
|
||||
await userEvent.type(inputs[0], 'Updated Metric 1');
|
||||
|
||||
// Every character typed triggers an update event. Let's check the last emission.
|
||||
const allEmits = emitted('update:modelValue');
|
||||
expect(allEmits).toBeTruthy();
|
||||
// The last emission should contain the fully updated name
|
||||
const lastEmission = allEmits[allEmits.length - 1];
|
||||
expect(lastEmission).toEqual([[{ name: 'Updated Metric 1' }]]);
|
||||
});
|
||||
|
||||
it('should render correctly with no initial metrics', () => {
|
||||
props.modelValue = [];
|
||||
const { queryAllByRole, getByText } = renderComponent({ props });
|
||||
const inputs = queryAllByRole('textbox');
|
||||
expect(inputs).toHaveLength(0);
|
||||
expect(getByText('New metric')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle adding multiple metrics', async () => {
|
||||
const { getByText, emitted } = renderComponent({ props });
|
||||
const addButton = getByText('New metric');
|
||||
|
||||
await userEvent.click(addButton);
|
||||
await userEvent.click(addButton);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
// Each click adds a new metric
|
||||
const updateEvents = emitted('update:modelValue');
|
||||
expect(updateEvents).toHaveLength(3);
|
||||
|
||||
// Check the structure of one of the emissions
|
||||
// Initial: [{ name: 'Metric 1' }, { name: 'Metric 2' }]
|
||||
// After first click: [{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]
|
||||
expect(updateEvents[0]).toEqual([[...props.modelValue, { name: '' }]]);
|
||||
});
|
||||
|
||||
it('should emit "deleteMetric" event when a delete button is clicked', async () => {
|
||||
const { getAllByRole, emitted } = renderComponent({ props });
|
||||
|
||||
// Each metric row has a delete button, identified by "button"
|
||||
const deleteButtons = getAllByRole('button', { name: '' });
|
||||
expect(deleteButtons).toHaveLength(props.modelValue.length);
|
||||
|
||||
// Click on the delete button for the second metric
|
||||
await userEvent.click(deleteButtons[1]);
|
||||
|
||||
expect(emitted('deleteMetric')).toBeTruthy();
|
||||
expect(emitted('deleteMetric')[0]).toEqual([props.modelValue[1]]);
|
||||
});
|
||||
|
||||
it('should emit multiple update events as the user types and reflect the final name correctly', async () => {
|
||||
const { getAllByPlaceholderText, emitted } = renderComponent({
|
||||
props: {
|
||||
modelValue: [{ name: '' }],
|
||||
},
|
||||
});
|
||||
const inputs = getAllByPlaceholderText('e.g. latency');
|
||||
await userEvent.type(inputs[0], 'ABC');
|
||||
|
||||
const allEmits = emitted('update:modelValue');
|
||||
expect(allEmits).toBeTruthy();
|
||||
// Each character typed should emit a new value
|
||||
expect(allEmits.length).toBe(3);
|
||||
expect(allEmits[2]).toEqual([[{ name: 'ABC' }]]);
|
||||
});
|
||||
|
||||
it('should not break if metrics are empty and still allow adding a new metric', async () => {
|
||||
props.modelValue = [];
|
||||
const { queryAllByRole, getByText, emitted } = renderComponent({ props });
|
||||
|
||||
// No metrics initially
|
||||
const inputs = queryAllByRole('textbox');
|
||||
expect(inputs).toHaveLength(0);
|
||||
|
||||
const addButton = getByText('New metric');
|
||||
await userEvent.click(addButton);
|
||||
|
||||
const updates = emitted('update:modelValue');
|
||||
expect(updates).toBeTruthy();
|
||||
expect(updates[0]).toEqual([[{ name: '' }]]);
|
||||
|
||||
// After adding one metric, we should now have an input
|
||||
const { getAllByPlaceholderText } = renderComponent({
|
||||
props: { modelValue: [{ name: '' }] },
|
||||
});
|
||||
const updatedInputs = getAllByPlaceholderText('e.g. latency');
|
||||
expect(updatedInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle deleting the first metric and still display remaining metrics correctly', async () => {
|
||||
const { getAllByPlaceholderText, getAllByRole, rerender, emitted } = renderComponent({
|
||||
props,
|
||||
});
|
||||
const inputs = getAllByPlaceholderText('e.g. latency');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
const deleteButtons = getAllByRole('button', { name: '' });
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
expect(emitted('deleteMetric')).toBeTruthy();
|
||||
expect(emitted('deleteMetric')[0]).toEqual([props.modelValue[0]]);
|
||||
|
||||
await rerender({ modelValue: [{ name: 'Metric 2' }] });
|
||||
const updatedInputs = getAllByPlaceholderText('e.g. latency');
|
||||
expect(updatedInputs).toHaveLength(1);
|
||||
expect(updatedInputs[0]).toHaveValue('Metric 2');
|
||||
});
|
||||
});
|
||||
|
|
@ -48,20 +48,12 @@ describe('useTestDefinitionForm', () => {
|
|||
expect(state.value.description.value).toBe('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
expect(state.value.tags.value).toEqual([]);
|
||||
expect(state.value.metrics).toEqual([]);
|
||||
expect(state.value.evaluationWorkflow.value).toBe('');
|
||||
});
|
||||
|
||||
it('should load test data', async () => {
|
||||
const { loadTestData, state } = useTestDefinitionForm();
|
||||
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
|
||||
const fetchMetricsSpy = vi.spyOn(useTestDefinitionStore(), 'fetchMetrics').mockResolvedValue([
|
||||
{
|
||||
id: 'metric1',
|
||||
name: 'Metric 1',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
},
|
||||
]);
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
evaluationsStore.testDefinitionsById = {
|
||||
|
|
@ -71,14 +63,10 @@ describe('useTestDefinitionForm', () => {
|
|||
|
||||
await loadTestData(TEST_DEF_A.id, '123');
|
||||
expect(fetchSpy).toBeCalled();
|
||||
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
|
||||
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
|
||||
expect(state.value.description.value).toEqual(TEST_DEF_A.description);
|
||||
expect(state.value.tags.value).toEqual([TEST_DEF_A.annotationTagId]);
|
||||
expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId);
|
||||
expect(state.value.metrics).toEqual([
|
||||
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should gracefully handle loadTestData when no test definition found', async () => {
|
||||
|
|
@ -94,7 +82,6 @@ describe('useTestDefinitionForm', () => {
|
|||
expect(state.value.description.value).toBe('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
expect(state.value.tags.value).toEqual([]);
|
||||
expect(state.value.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle errors while loading test data', async () => {
|
||||
|
|
@ -176,68 +163,6 @@ describe('useTestDefinitionForm', () => {
|
|||
expect(updateSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should delete a metric', async () => {
|
||||
const { state, deleteMetric } = useTestDefinitionForm();
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
const deleteMetricSpy = vi.spyOn(evaluationsStore, 'deleteMetric');
|
||||
|
||||
state.value.metrics = [
|
||||
{
|
||||
id: 'metric1',
|
||||
name: 'Metric 1',
|
||||
testDefinitionId: '1',
|
||||
},
|
||||
{
|
||||
id: 'metric2',
|
||||
name: 'Metric 2',
|
||||
testDefinitionId: '1',
|
||||
},
|
||||
];
|
||||
|
||||
await deleteMetric('metric1', TEST_DEF_A.id);
|
||||
expect(deleteMetricSpy).toBeCalledWith({ id: 'metric1', testDefinitionId: TEST_DEF_A.id });
|
||||
expect(state.value.metrics).toEqual([
|
||||
{ id: 'metric2', name: 'Metric 2', testDefinitionId: '1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update metrics', async () => {
|
||||
const { state, updateMetrics } = useTestDefinitionForm();
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
const updateMetricSpy = vi.spyOn(evaluationsStore, 'updateMetric');
|
||||
const createMetricSpy = vi
|
||||
.spyOn(evaluationsStore, 'createMetric')
|
||||
.mockResolvedValue({ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id });
|
||||
|
||||
state.value.metrics = [
|
||||
{
|
||||
id: 'metric1',
|
||||
name: 'Metric 1',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
name: 'Metric 2',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
}, // New metric that needs creation
|
||||
];
|
||||
|
||||
await updateMetrics(TEST_DEF_A.id);
|
||||
expect(createMetricSpy).toHaveBeenCalledWith({
|
||||
name: 'Metric 2',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
});
|
||||
expect(updateMetricSpy).toHaveBeenCalledWith({
|
||||
name: 'Metric 1',
|
||||
id: 'metric1',
|
||||
testDefinitionId: TEST_DEF_A.id,
|
||||
});
|
||||
expect(state.value.metrics).toEqual([
|
||||
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
|
||||
{ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should start editing a field', () => {
|
||||
const { state, startEditing } = useTestDefinitionForm();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { TestMetricRecord } from '@/api/testDefinition.ee';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
|
||||
export interface EditableField<T = string> {
|
||||
|
|
@ -14,6 +13,5 @@ export interface EditableFormState {
|
|||
|
||||
export interface EvaluationFormState extends EditableFormState {
|
||||
evaluationWorkflow: INodeParameterResourceLocator;
|
||||
metrics: TestMetricRecord[];
|
||||
mockedNodes: Array<{ name: string; id: string }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import VirtualSchema from '@/components/VirtualSchema.vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { cleanup, waitFor } from '@testing-library/vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import {
|
||||
createTestNode,
|
||||
defaultNodeDescriptions,
|
||||
mockNodeTypeDescription,
|
||||
} from '@/__tests__/mocks';
|
||||
import { IF_NODE_TYPE, SET_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import VirtualSchema from '@/components/VirtualSchema.vue';
|
||||
import * as nodeHelpers from '@/composables/useNodeHelpers';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { IF_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { cleanup, waitFor } from '@testing-library/vue';
|
||||
import {
|
||||
createResultOk,
|
||||
NodeConnectionTypes,
|
||||
type IDataObject,
|
||||
type IBinaryData,
|
||||
type INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import * as nodeHelpers from '@/composables/useNodeHelpers';
|
||||
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useSchemaPreviewStore } from '../stores/schemaPreview.store';
|
||||
import { usePostHog } from '../stores/posthog.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { defaultSettings } from '../__tests__/defaults';
|
||||
import { usePostHog } from '../stores/posthog.store';
|
||||
import { useSchemaPreviewStore } from '../stores/schemaPreview.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
|
||||
const mockNode1 = createTestNode({
|
||||
name: 'Manual Trigger',
|
||||
|
|
@ -65,6 +65,14 @@ const aiTool = createTestNode({
|
|||
disabled: false,
|
||||
});
|
||||
|
||||
const nodeWithCredential = createTestNode({
|
||||
name: 'Notion',
|
||||
type: 'n8n-nodes-base.notion',
|
||||
typeVersion: 1,
|
||||
credentials: { notionApi: { id: 'testId', name: 'testName' } },
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const unknownNodeType = createTestNode({
|
||||
name: 'Unknown Node Type',
|
||||
type: 'unknown',
|
||||
|
|
@ -76,15 +84,23 @@ const defaultNodes = [
|
|||
];
|
||||
|
||||
async function setupStore() {
|
||||
const workflow = mock<IWorkflowDb>({
|
||||
const workflow = {
|
||||
id: '123',
|
||||
name: 'Test Workflow',
|
||||
connections: {},
|
||||
active: true,
|
||||
nodes: [mockNode1, mockNode2, disabledNode, ifNode, aiTool, unknownNodeType],
|
||||
});
|
||||
nodes: [
|
||||
mockNode1,
|
||||
mockNode2,
|
||||
disabledNode,
|
||||
ifNode,
|
||||
aiTool,
|
||||
unknownNodeType,
|
||||
nodeWithCredential,
|
||||
],
|
||||
};
|
||||
|
||||
const pinia = createPinia();
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
|
@ -102,20 +118,24 @@ async function setupStore() {
|
|||
name: IF_NODE_TYPE,
|
||||
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
||||
}),
|
||||
mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-base.notion',
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
}),
|
||||
]);
|
||||
workflowsStore.workflow = workflow;
|
||||
workflowsStore.workflow = workflow as IWorkflowDb;
|
||||
|
||||
return pinia;
|
||||
}
|
||||
|
||||
function mockNodeOutputData(nodeName: string, data: IDataObject[], outputIndex = 0) {
|
||||
function mockNodeOutputData(nodeName: string, data: INodeExecutionData[], outputIndex = 0) {
|
||||
const originalNodeHelpers = nodeHelpers.useNodeHelpers();
|
||||
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
|
||||
return {
|
||||
...originalNodeHelpers,
|
||||
getNodeInputData: vi.fn((node, _, output) => {
|
||||
if (node.name === nodeName && output === outputIndex) {
|
||||
return data.map((json) => ({ json }));
|
||||
return data;
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
|
|
@ -146,7 +166,7 @@ describe('VirtualSchema.vue', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
cleanup();
|
||||
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(123);
|
||||
vi.resetAllMocks();
|
||||
vi.setSystemTime('2025-01-01');
|
||||
renderComponent = createComponentRenderer(VirtualSchema, {
|
||||
global: {
|
||||
|
|
@ -183,6 +203,20 @@ describe('VirtualSchema.vue', () => {
|
|||
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders schema for empty data with binary', async () => {
|
||||
mockNodeOutputData(mockNode1.name, [{ json: {}, binary: { data: mock<IBinaryData>() } }]);
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
props: { nodes: [{ name: mockNode1.name, indicies: [], depth: 1 }] },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
getByText("Only binary data exists. View it using the 'Binary' tab"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders schema for data', async () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
|
|
@ -307,10 +341,7 @@ describe('VirtualSchema.vue', () => {
|
|||
it('renders schema for correct output branch', async () => {
|
||||
mockNodeOutputData(
|
||||
'If',
|
||||
[
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
[{ json: { id: 1, name: 'John' } }, { json: { id: 2, name: 'Jane' } }],
|
||||
1,
|
||||
);
|
||||
const { getAllByTestId } = renderComponent({
|
||||
|
|
@ -330,10 +361,7 @@ describe('VirtualSchema.vue', () => {
|
|||
it('renders previous nodes schema for AI tools', async () => {
|
||||
mockNodeOutputData(
|
||||
'If',
|
||||
[
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
[{ json: { id: 1, name: 'John' } }, { json: { id: 2, name: 'Jane' } }],
|
||||
0,
|
||||
);
|
||||
const { getAllByTestId } = renderComponent({
|
||||
|
|
@ -481,14 +509,6 @@ describe('VirtualSchema.vue', () => {
|
|||
});
|
||||
|
||||
it('should track data pill drag and drop for schema preview', async () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: {
|
||||
...mockNode2,
|
||||
credentials: { myCredential: { id: 'myCredential', name: 'myCredential' } },
|
||||
},
|
||||
data: [],
|
||||
});
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const trackSpy = vi.spyOn(telemetry, 'track');
|
||||
const posthogStore = usePostHog();
|
||||
|
|
@ -513,7 +533,7 @@ describe('VirtualSchema.vue', () => {
|
|||
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
nodes: [{ name: mockNode2.name, indicies: [], depth: 1 }],
|
||||
nodes: [{ name: nodeWithCredential.name, indicies: [], depth: 1 }],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -639,4 +659,8 @@ describe('VirtualSchema.vue', () => {
|
|||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should do something', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,21 +105,11 @@ const toggleNodeAndScrollTop = (id: string) => {
|
|||
const getNodeSchema = async (fullNode: INodeUi, connectedNode: IConnectedNode) => {
|
||||
const pinData = workflowsStore.pinDataByNodeName(connectedNode.name);
|
||||
const connectedOutputIndexes = connectedNode.indicies.length > 0 ? connectedNode.indicies : [0];
|
||||
const data =
|
||||
pinData ??
|
||||
connectedOutputIndexes
|
||||
.map((outputIndex) =>
|
||||
executionDataToJson(
|
||||
getNodeInputData(
|
||||
fullNode,
|
||||
props.runIndex,
|
||||
outputIndex,
|
||||
props.paneType,
|
||||
props.connectionType,
|
||||
),
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
const nodeData = connectedOutputIndexes.map((outputIndex) =>
|
||||
getNodeInputData(fullNode, props.runIndex, outputIndex, props.paneType, props.connectionType),
|
||||
);
|
||||
const hasBinary = nodeData.flat().some((data) => !isEmpty(data.binary));
|
||||
const data = pinData ?? nodeData.map(executionDataToJson).flat();
|
||||
|
||||
let schema = getSchemaForExecutionData(data);
|
||||
let preview = false;
|
||||
|
|
@ -137,6 +127,7 @@ const getNodeSchema = async (fullNode: INodeUi, connectedNode: IConnectedNode) =
|
|||
connectedOutputIndexes,
|
||||
itemsCount: data.length,
|
||||
preview,
|
||||
hasBinary,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -251,7 +242,7 @@ const nodesSchemas = asyncComputed<SchemaNode[]>(async () => {
|
|||
const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion);
|
||||
if (!nodeType) continue;
|
||||
|
||||
const { schema, connectedOutputIndexes, itemsCount, preview } = await getNodeSchema(
|
||||
const { schema, connectedOutputIndexes, itemsCount, preview, hasBinary } = await getNodeSchema(
|
||||
fullNode,
|
||||
node,
|
||||
);
|
||||
|
|
@ -268,6 +259,7 @@ const nodesSchemas = asyncComputed<SchemaNode[]>(async () => {
|
|||
nodeType,
|
||||
schema: filteredSchema,
|
||||
preview,
|
||||
hasBinary,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -424,6 +425,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -810,6 +812,7 @@ exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = `
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -882,6 +885,7 @@ exports[`VirtualSchema.vue > renders schema for correct output branch 1`] = `
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -1414,6 +1418,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -1976,6 +1981,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -2168,6 +2174,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = `
|
|||
>
|
||||
<img
|
||||
class="nodeIconImage"
|
||||
src="/nodes/test-node/icon.svg"
|
||||
/>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/dat
|
|||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { useVueFlow } from '@vue-flow/core';
|
||||
import { SIMULATE_NODE_TYPE } from '@/constants';
|
||||
|
||||
const matchMedia = global.window.matchMedia;
|
||||
// @ts-expect-error Initialize window object
|
||||
|
|
@ -273,4 +274,30 @@ describe('Canvas', () => {
|
|||
expect(patternCanvas?.innerHTML).not.toContain('<circle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulate', () => {
|
||||
it('should render simulate node', async () => {
|
||||
const nodes = [
|
||||
createCanvasNodeElement({
|
||||
id: '1',
|
||||
label: 'Node',
|
||||
position: { x: 200, y: 200 },
|
||||
data: {
|
||||
type: SIMULATE_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
nodes,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
|
||||
|
||||
expect(container.querySelector('.icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user