Merge remote-tracking branch 'origin/master' into ADO-3305

This commit is contained in:
Charlie Kolb 2025-03-26 08:46:09 +01:00
commit 8b147f905d
No known key found for this signature in database
172 changed files with 4590 additions and 2075 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/');
});
});

View File

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

View File

@ -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', () => {

View File

@ -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": {

View File

@ -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'],

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.84.0",
"version": "1.85.0",
"private": true,
"engines": {
"node": ">=20.15",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.19.0",
"version": "0.20.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

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

View File

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

View File

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

View File

@ -32,4 +32,6 @@ export type {
InsightsSummaryType,
InsightsSummaryUnit,
InsightsSummary,
InsightsByWorkflow,
InsightsByTime,
} from './schemas/insights.schema';

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.32.0",
"version": "1.33.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -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',
},
};
}

View File

@ -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',
],
},
},

View File

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

View File

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

View File

@ -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,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/permissions",
"version": "0.20.0",
"version": "0.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/utils",
"type": "module",
"version": "1.4.0",
"version": "1.5.0",
"files": [
"dist"
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]
}
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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];
}

View File

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

View File

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

View File

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

View File

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

View 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;
}

View 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();
}
}

View File

@ -6,6 +6,8 @@ import { N8nModule } from '@/decorators/module';
import { InsightsService } from './insights.service';
import './insights.controller';
@N8nModule()
export class InsightsModule implements BaseN8nModule {
constructor(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": {

View File

@ -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": {

View File

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

View File

@ -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 }),
];

View File

@ -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}` : ''}`;

View File

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

View File

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

View File

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

View File

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

View File

@ -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
>&nbsp;
<n8n-text :bold="true">
<n8n-text size="small" bold="true">
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
>
</n8n-callout>

View File

@ -76,7 +76,7 @@ const connectedNodes = computed<
).reverse(),
[FloatingNodePosition.left]: getINodesFromNames(
workflow.getParentNodes(rootName, NodeConnectionTypes.Main, 1),
),
).reverse(),
};
});

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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