mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' into ai-508-backend-cancel-test-run
# Conflicts: # packages/cli/src/databases/repositories/test-run.repository.ee.ts # packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts # packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts
This commit is contained in:
commit
8c66a049b7
|
@ -3,6 +3,7 @@ import * as formStep from '../composables/setup-template-form-step';
|
||||||
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
|
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
|
||||||
import TestTemplate1 from '../fixtures/Test_Template_1.json';
|
import TestTemplate1 from '../fixtures/Test_Template_1.json';
|
||||||
import TestTemplate2 from '../fixtures/Test_Template_2.json';
|
import TestTemplate2 from '../fixtures/Test_Template_2.json';
|
||||||
|
import { clearNotifications } from '../pages/notifications';
|
||||||
import {
|
import {
|
||||||
clickUseWorkflowButtonByTitle,
|
clickUseWorkflowButtonByTitle,
|
||||||
visitTemplateCollectionPage,
|
visitTemplateCollectionPage,
|
||||||
|
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
templateCredentialsSetupPage.finishCredentialSetup();
|
templateCredentialsSetupPage.finishCredentialSetup();
|
||||||
|
|
||||||
workflowPage.getters.canvasNodes().should('have.length', 3);
|
workflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
|
|
||||||
|
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||||
|
|
||||||
// Focus the canvas so the copy to clipboard works
|
// Focus the canvas so the copy to clipboard works
|
||||||
workflowPage.getters.canvasNodes().eq(0).realClick();
|
workflowPage.getters.canvasNodes().eq(0).realClick();
|
||||||
workflowPage.actions.hitSelectAll();
|
workflowPage.actions.hitSelectAll();
|
||||||
workflowPage.actions.hitCopy();
|
workflowPage.actions.hitCopy();
|
||||||
|
|
||||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
|
||||||
// Check workflow JSON by copying it to clipboard
|
// Check workflow JSON by copying it to clipboard
|
||||||
cy.readClipboard().then((workflowJSON) => {
|
cy.readClipboard().then((workflowJSON) => {
|
||||||
const workflow = JSON.parse(workflowJSON);
|
const workflow = JSON.parse(workflowJSON);
|
||||||
|
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
templateCredentialsSetupPage.finishCredentialSetup();
|
templateCredentialsSetupPage.finishCredentialSetup();
|
||||||
|
|
||||||
workflowPage.getters.canvasNodes().should('have.length', 3);
|
workflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
|
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
|
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
templateCredentialsSetupPage.finishCredentialSetup();
|
templateCredentialsSetupPage.finishCredentialSetup();
|
||||||
|
|
||||||
getSetupWorkflowCredentialsButton().should('be.visible');
|
getSetupWorkflowCredentialsButton().should('be.visible');
|
||||||
|
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
|
||||||
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
setupCredsModal.closeModalFromContinueButton();
|
setupCredsModal.closeModalFromContinueButton();
|
||||||
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');
|
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
"value": {},
|
"value": {},
|
||||||
"matchingColumns": [],
|
"matchingColumns": [],
|
||||||
"schema": [],
|
"schema": [],
|
||||||
"ignoreTypeMismatchErrors": false,
|
|
||||||
"attemptToConvertTypes": false,
|
"attemptToConvertTypes": false,
|
||||||
"convertFieldsToString": true
|
"convertFieldsToString": true
|
||||||
},
|
},
|
||||||
|
|
|
@ -61,6 +61,7 @@ export class CredentialsModal extends BasePage {
|
||||||
this.getters
|
this.getters
|
||||||
.credentialInputs()
|
.credentialInputs()
|
||||||
.find('input[type=text], input[type=password]')
|
.find('input[type=text], input[type=password]')
|
||||||
|
.filter(':not([readonly])')
|
||||||
.each(($el) => {
|
.each(($el) => {
|
||||||
cy.wrap($el).type('test');
|
cy.wrap($el).type('test');
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
|
||||||
* Actions
|
* Actions
|
||||||
*/
|
*/
|
||||||
export const clearNotifications = () => {
|
export const clearNotifications = () => {
|
||||||
successToast().find('.el-notification__closeBtn').click({ multiple: true });
|
const buttons = successToast().find('.el-notification__closeBtn');
|
||||||
|
buttons.then(($buttons) => {
|
||||||
|
if ($buttons.length) {
|
||||||
|
buttons.click({ multiple: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -131,6 +131,7 @@ export class LmChatGoogleVertex implements INodeType {
|
||||||
const credentials = await this.getCredentials('googleApi');
|
const credentials = await this.getCredentials('googleApi');
|
||||||
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
const privateKey = formatPrivateKey(credentials.privateKey as string);
|
||||||
const email = (credentials.email as string).trim();
|
const email = (credentials.email as string).trim();
|
||||||
|
const region = credentials.region as string;
|
||||||
|
|
||||||
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
|
||||||
|
|
||||||
|
@ -165,6 +166,7 @@ export class LmChatGoogleVertex implements INodeType {
|
||||||
private_key: privateKey,
|
private_key: privateKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
location: region,
|
||||||
model: modelName,
|
model: modelName,
|
||||||
topK: options.topK,
|
topK: options.topK,
|
||||||
topP: options.topP,
|
topP: options.topP,
|
||||||
|
|
|
@ -35,4 +35,23 @@ export class TestRun extends WithTimestampsAndStringId {
|
||||||
|
|
||||||
@Column(jsonColumnType, { nullable: true })
|
@Column(jsonColumnType, { nullable: true })
|
||||||
metrics: AggregatedTestRunMetrics;
|
metrics: AggregatedTestRunMetrics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of the test cases, matching the filter condition of the test definition (specified annotationTag)
|
||||||
|
*/
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
totalCases: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of test cases that passed (evaluation workflow was executed successfully)
|
||||||
|
*/
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
passedCases: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of failed test cases
|
||||||
|
* (any unexpected exception happened during the execution or evaluation workflow ended with an error)
|
||||||
|
*/
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
failedCases: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
|
||||||
|
|
||||||
|
const columns = ['totalCases', 'passedCases', 'failedCases'] as const;
|
||||||
|
|
||||||
|
export class AddStatsColumnsToTestRun1736172058779 implements ReversibleMigration {
|
||||||
|
async up({ escape, runQuery }: MigrationContext) {
|
||||||
|
const tableName = escape.tableName('test_run');
|
||||||
|
const columnNames = columns.map((name) => escape.columnName(name));
|
||||||
|
|
||||||
|
// Values can be NULL only if the test run is new, otherwise they must be non-negative integers.
|
||||||
|
// Test run might be cancelled or interrupted by unexpected error at any moment, so values can be either NULL or non-negative integers.
|
||||||
|
for (const name of columnNames) {
|
||||||
|
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${name} INT CHECK(
|
||||||
|
CASE
|
||||||
|
WHEN status = 'new' THEN ${name} IS NULL
|
||||||
|
WHEN status in ('cancelled', 'error') THEN ${name} IS NULL OR ${name} >= 0
|
||||||
|
ELSE ${name} >= 0
|
||||||
|
END
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down({ escape, runQuery }: MigrationContext) {
|
||||||
|
const tableName = escape.tableName('test_run');
|
||||||
|
const columnNames = columns.map((name) => escape.columnName(name));
|
||||||
|
|
||||||
|
for (const name of columnNames) {
|
||||||
|
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||||
|
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -154,4 +155,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
AddProjectIcons1729607673469,
|
AddProjectIcons1729607673469,
|
||||||
|
AddStatsColumnsToTestRun1736172058779,
|
||||||
];
|
];
|
||||||
|
|
|
@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||||
|
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -154,4 +155,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
AddProjectIcons1729607673469,
|
AddProjectIcons1729607673469,
|
||||||
|
AddStatsColumnsToTestRun1736172058779,
|
||||||
];
|
];
|
||||||
|
|
|
@ -73,6 +73,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
|
||||||
|
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -148,6 +149,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
AddProjectIcons1729607673469,
|
AddProjectIcons1729607673469,
|
||||||
|
AddStatsColumnsToTestRun1736172058779,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -21,8 +21,14 @@ export class TestRunRepository extends Repository<TestRun> {
|
||||||
return await this.save(testRun);
|
return await this.save(testRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRunning(id: string) {
|
async markAsRunning(id: string, totalCases: number) {
|
||||||
return await this.update(id, { status: 'running', runAt: new Date() });
|
return await this.update(id, {
|
||||||
|
status: 'running',
|
||||||
|
runAt: new Date(),
|
||||||
|
totalCases,
|
||||||
|
passedCases: 0,
|
||||||
|
failedCases: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
|
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
|
||||||
|
@ -33,6 +39,14 @@ export class TestRunRepository extends Repository<TestRun> {
|
||||||
return await this.update(id, { status: 'cancelled' });
|
return await this.update(id, { status: 'cancelled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async incrementPassed(id: string) {
|
||||||
|
return await this.increment({ id }, 'passedCases', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementFailed(id: string) {
|
||||||
|
return await this.increment({ id }, 'failedCases', 1);
|
||||||
|
}
|
||||||
|
|
||||||
async getMany(testDefinitionId: string, options: ListQuery.Options) {
|
async getMany(testDefinitionId: string, options: ListQuery.Options) {
|
||||||
const findManyOptions: FindManyOptions<TestRun> = {
|
const findManyOptions: FindManyOptions<TestRun> = {
|
||||||
where: { testDefinition: { id: testDefinitionId } },
|
where: { testDefinition: { id: testDefinitionId } },
|
||||||
|
|
|
@ -2,7 +2,8 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
import { stringify } from 'flatted';
|
import { stringify } from 'flatted';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { mock, mockDeep } from 'jest-mock-extended';
|
import { mock, mockDeep } from 'jest-mock-extended';
|
||||||
import type { GenericValue, IRun } from 'n8n-workflow';
|
import type { ErrorReporter } from 'n8n-core';
|
||||||
|
import type { ExecutionError, GenericValue, IRun } from 'n8n-workflow';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import type { ActiveExecutions } from '@/active-executions';
|
import type { ActiveExecutions } from '@/active-executions';
|
||||||
|
@ -90,6 +91,16 @@ function mockExecutionData() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockErrorExecutionData() {
|
||||||
|
return mock<IRun>({
|
||||||
|
data: {
|
||||||
|
resultData: {
|
||||||
|
error: mock<ExecutionError>(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
||||||
return mock<IRun>({
|
return mock<IRun>({
|
||||||
data: {
|
data: {
|
||||||
|
@ -110,6 +121,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// error is an optional prop, but jest-mock-extended will mock it by default,
|
||||||
|
// which affects the code logic. So, we need to explicitly set it to undefined.
|
||||||
|
error: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -158,6 +172,8 @@ describe('TestRunnerService', () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
|
testRunRepository.incrementFailed.mockClear();
|
||||||
|
testRunRepository.incrementPassed.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create an instance of TestRunnerService', async () => {
|
test('should create an instance of TestRunnerService', async () => {
|
||||||
|
@ -169,6 +185,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||||
|
@ -183,6 +200,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
@ -220,6 +238,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
@ -300,12 +319,185 @@ describe('TestRunnerService', () => {
|
||||||
// Check Test Run status was updated correctly
|
// Check Test Run status was updated correctly
|
||||||
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
|
||||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
|
||||||
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
|
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
|
||||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||||
metric1: 0.75,
|
metric1: 0.75,
|
||||||
metric2: 0,
|
metric2: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
|
||||||
|
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should properly count passed and failed executions', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
testRunRepository,
|
||||||
|
testMetricRepository,
|
||||||
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||||
|
id: 'evaluation-workflow-id',
|
||||||
|
...wfEvaluationJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||||
|
|
||||||
|
// Mock executions of workflow under test
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-3')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
// Mock executions of evaluation workflow
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-2')
|
||||||
|
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-4')
|
||||||
|
.mockRejectedValue(new Error('Some error'));
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
|
mockedNodes: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should properly count failed test executions', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
testRunRepository,
|
||||||
|
testMetricRepository,
|
||||||
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||||
|
id: 'evaluation-workflow-id',
|
||||||
|
...wfEvaluationJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||||
|
|
||||||
|
// Mock executions of workflow under test
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-3')
|
||||||
|
.mockResolvedValue(mockErrorExecutionData());
|
||||||
|
|
||||||
|
// Mock executions of evaluation workflow
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-2')
|
||||||
|
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
|
mockedNodes: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should properly count failed evaluations', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
testRunRepository,
|
||||||
|
testMetricRepository,
|
||||||
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||||
|
id: 'evaluation-workflow-id',
|
||||||
|
...wfEvaluationJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||||
|
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||||
|
|
||||||
|
// Mock executions of workflow under test
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-3')
|
||||||
|
.mockResolvedValue(mockExecutionData());
|
||||||
|
|
||||||
|
// Mock executions of evaluation workflow
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-2')
|
||||||
|
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||||
|
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith('some-execution-id-4')
|
||||||
|
.mockResolvedValue(mockErrorExecutionData());
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
|
mockedNodes: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should specify correct start nodes when running workflow under test', async () => {
|
test('should specify correct start nodes when running workflow under test', async () => {
|
||||||
|
@ -317,6 +509,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
@ -390,6 +583,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||||
|
@ -414,6 +608,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||||
|
@ -443,6 +638,7 @@ describe('TestRunnerService', () => {
|
||||||
testRunRepository,
|
testRunRepository,
|
||||||
testMetricRepository,
|
testMetricRepository,
|
||||||
mockNodeTypes,
|
mockNodeTypes,
|
||||||
|
mock<ErrorReporter>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { parse } from 'flatted';
|
import { parse } from 'flatted';
|
||||||
|
import { ErrorReporter } from 'n8n-core';
|
||||||
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
|
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
|
@ -48,6 +49,7 @@ export class TestRunnerService {
|
||||||
private readonly testRunRepository: TestRunRepository,
|
private readonly testRunRepository: TestRunRepository,
|
||||||
private readonly testMetricRepository: TestMetricRepository,
|
private readonly testMetricRepository: TestMetricRepository,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
|
private readonly errorReporter: ErrorReporter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -260,7 +262,7 @@ export class TestRunnerService {
|
||||||
const testMetricNames = await this.getTestMetricNames(test.id);
|
const testMetricNames = await this.getTestMetricNames(test.id);
|
||||||
|
|
||||||
// 2. Run over all the test cases
|
// 2. Run over all the test cases
|
||||||
await this.testRunRepository.markAsRunning(testRun.id);
|
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
|
||||||
|
|
||||||
// Object to collect the results of the evaluation workflow executions
|
// Object to collect the results of the evaluation workflow executions
|
||||||
const metrics = new EvaluationMetrics(testMetricNames);
|
const metrics = new EvaluationMetrics(testMetricNames);
|
||||||
|
@ -270,6 +272,7 @@ export class TestRunnerService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Fetch past execution with data
|
// Fetch past execution with data
|
||||||
const pastExecution = await this.executionRepository.findOne({
|
const pastExecution = await this.executionRepository.findOne({
|
||||||
where: { id: pastExecutionId },
|
where: { id: pastExecutionId },
|
||||||
|
@ -290,8 +293,9 @@ export class TestRunnerService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// In case of a permission check issue, the test case execution will be undefined.
|
// In case of a permission check issue, the test case execution will be undefined.
|
||||||
// Skip them and continue with the next test case
|
// Skip them, increment the failed count and continue with the next test case
|
||||||
if (!testCaseExecution) {
|
if (!testCaseExecution) {
|
||||||
|
await this.testRunRepository.incrementFailed(testRun.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -313,6 +317,17 @@ export class TestRunnerService {
|
||||||
|
|
||||||
// Extract the output of the last node executed in the evaluation workflow
|
// Extract the output of the last node executed in the evaluation workflow
|
||||||
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
||||||
|
if (evalExecution.data.resultData.error) {
|
||||||
|
await this.testRunRepository.incrementFailed(testRun.id);
|
||||||
|
} else {
|
||||||
|
await this.testRunRepository.incrementPassed(testRun.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// In case of an unexpected error, increment the failed count and continue with the next test case
|
||||||
|
await this.testRunRepository.incrementFailed(testRun.id);
|
||||||
|
|
||||||
|
this.errorReporter.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the test run as completed or cancelled
|
// Mark the test run as completed or cancelled
|
||||||
|
|
|
@ -5,7 +5,9 @@ import type { INode, INodesGraphResult } from 'n8n-workflow';
|
||||||
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
|
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import { N8N_VERSION } from '@/constants';
|
import { N8N_VERSION } from '@/constants';
|
||||||
|
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
import type { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
||||||
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
const nodeTypes = mock<NodeTypes>();
|
const nodeTypes = mock<NodeTypes>();
|
||||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||||
|
const credentialsRepository = mock<CredentialsRepository>();
|
||||||
const eventService = new EventService();
|
const eventService = new EventService();
|
||||||
|
|
||||||
let telemetryEventRelay: TelemetryEventRelay;
|
let telemetryEventRelay: TelemetryEventRelay;
|
||||||
|
@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
projectRelationRepository,
|
projectRelationRepository,
|
||||||
|
credentialsRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
await telemetryEventRelay.init();
|
await telemetryEventRelay.init();
|
||||||
|
@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
projectRelationRepository,
|
projectRelationRepository,
|
||||||
|
credentialsRepository,
|
||||||
);
|
);
|
||||||
// @ts-expect-error Private method
|
// @ts-expect-error Private method
|
||||||
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
||||||
|
@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => {
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
sharedWorkflowRepository,
|
sharedWorkflowRepository,
|
||||||
projectRelationRepository,
|
projectRelationRepository,
|
||||||
|
credentialsRepository,
|
||||||
);
|
);
|
||||||
// @ts-expect-error Private method
|
// @ts-expect-error Private method
|
||||||
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
|
||||||
|
@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => {
|
||||||
|
|
||||||
it('should call telemetry.track when manual node execution finished', async () => {
|
it('should call telemetry.track when manual node execution finished', async () => {
|
||||||
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||||
|
credentialsRepository.findOneBy.mockResolvedValue(
|
||||||
|
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
|
||||||
|
);
|
||||||
|
|
||||||
const runData = {
|
const runData = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => {
|
||||||
error_node_id: '1',
|
error_node_id: '1',
|
||||||
node_id: '1',
|
node_id: '1',
|
||||||
node_type: 'n8n-nodes-base.jira',
|
node_type: 'n8n-nodes-base.jira',
|
||||||
|
is_managed: false,
|
||||||
|
credential_type: null,
|
||||||
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -1498,5 +1509,118 @@ describe('TelemetryEventRelay', () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call telemetry.track when manual node execution finished with is_managed and credential_type properties', async () => {
|
||||||
|
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
|
||||||
|
credentialsRepository.findOneBy.mockResolvedValue(
|
||||||
|
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runData = {
|
||||||
|
status: 'error',
|
||||||
|
mode: 'manual',
|
||||||
|
data: {
|
||||||
|
executionData: {
|
||||||
|
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||||||
|
},
|
||||||
|
startData: {
|
||||||
|
destinationNode: 'OpenAI',
|
||||||
|
runNodeFilter: ['OpenAI'],
|
||||||
|
},
|
||||||
|
resultData: {
|
||||||
|
runData: {},
|
||||||
|
lastNodeExecuted: 'OpenAI',
|
||||||
|
error: new NodeApiError(
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
name: 'Jira',
|
||||||
|
type: 'n8n-nodes-base.jira',
|
||||||
|
parameters: {},
|
||||||
|
position: [100, 200],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Error message',
|
||||||
|
description: 'Incorrect API key provided',
|
||||||
|
httpCode: '401',
|
||||||
|
stack: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Error message',
|
||||||
|
description: 'Error description',
|
||||||
|
level: 'warning',
|
||||||
|
functionality: 'regular',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as IRun;
|
||||||
|
|
||||||
|
const nodeGraph: INodesGraphResult = {
|
||||||
|
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
|
||||||
|
nameIndices: {
|
||||||
|
Jira: '1',
|
||||||
|
OpenAI: '1',
|
||||||
|
},
|
||||||
|
} as unknown as INodesGraphResult;
|
||||||
|
|
||||||
|
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
|
||||||
|
.mockImplementation(
|
||||||
|
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const event: RelayEventMap['workflow-post-execute'] = {
|
||||||
|
workflow: mockWorkflowBase,
|
||||||
|
executionId: 'execution123',
|
||||||
|
userId: 'user123',
|
||||||
|
runData,
|
||||||
|
};
|
||||||
|
|
||||||
|
eventService.emit('workflow-post-execute', event);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
|
||||||
|
id: 'nhu-l8E4hX',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(telemetry.track).toHaveBeenCalledWith(
|
||||||
|
'Manual node exec finished',
|
||||||
|
expect.objectContaining({
|
||||||
|
webhook_domain: null,
|
||||||
|
user_id: 'user123',
|
||||||
|
workflow_id: 'workflow123',
|
||||||
|
status: 'error',
|
||||||
|
executionStatus: 'error',
|
||||||
|
sharing_role: 'sharee',
|
||||||
|
error_message: 'Error message',
|
||||||
|
error_node_type: 'n8n-nodes-base.jira',
|
||||||
|
error_node_id: '1',
|
||||||
|
node_id: '1',
|
||||||
|
node_type: 'n8n-nodes-base.jira',
|
||||||
|
|
||||||
|
is_managed: true,
|
||||||
|
credential_type: 'openAiApi',
|
||||||
|
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workflow_id: 'workflow123',
|
||||||
|
success: false,
|
||||||
|
is_manual: true,
|
||||||
|
execution_mode: 'manual',
|
||||||
|
version_cli: N8N_VERSION,
|
||||||
|
error_message: 'Error message',
|
||||||
|
error_node_type: 'n8n-nodes-base.jira',
|
||||||
|
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
|
||||||
|
error_node_id: '1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { get as pslGet } from 'psl';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { N8N_VERSION } from '@/constants';
|
import { N8N_VERSION } from '@/constants';
|
||||||
|
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
|
||||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||||
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
) {
|
) {
|
||||||
super(eventService);
|
super(eventService);
|
||||||
}
|
}
|
||||||
|
@ -693,6 +695,8 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
error_node_id: telemetryProperties.error_node_id as string,
|
error_node_id: telemetryProperties.error_node_id as string,
|
||||||
webhook_domain: null,
|
webhook_domain: null,
|
||||||
sharing_role: userRole,
|
sharing_role: userRole,
|
||||||
|
credential_type: null,
|
||||||
|
is_managed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!manualExecEventProperties.node_graph_string) {
|
if (!manualExecEventProperties.node_graph_string) {
|
||||||
|
@ -703,7 +707,18 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runData.data.startData?.destinationNode) {
|
if (runData.data.startData?.destinationNode) {
|
||||||
const telemetryPayload = {
|
const credentialsData = TelemetryHelpers.extractLastExecutedNodeCredentialData(runData);
|
||||||
|
if (credentialsData) {
|
||||||
|
manualExecEventProperties.credential_type = credentialsData.credentialType;
|
||||||
|
const credential = await this.credentialsRepository.findOneBy({
|
||||||
|
id: credentialsData.credentialId,
|
||||||
|
});
|
||||||
|
if (credential) {
|
||||||
|
manualExecEventProperties.is_managed = credential.isManaged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const telemetryPayload: ITelemetryTrackProperties = {
|
||||||
...manualExecEventProperties,
|
...manualExecEventProperties,
|
||||||
node_type: TelemetryHelpers.getNodeTypeForName(
|
node_type: TelemetryHelpers.getNodeTypeForName(
|
||||||
workflow,
|
workflow,
|
||||||
|
|
|
@ -97,7 +97,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
|
||||||
const testRunRepository = Container.get(TestRunRepository);
|
const testRunRepository = Container.get(TestRunRepository);
|
||||||
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
|
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
|
||||||
// Mark as running just to make a slight delay between the runs
|
// Mark as running just to make a slight delay between the runs
|
||||||
await testRunRepository.markAsRunning(testRun1.id);
|
await testRunRepository.markAsRunning(testRun1.id, 10);
|
||||||
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
|
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
|
||||||
|
|
||||||
// Fetch the first page
|
// Fetch the first page
|
||||||
|
|
|
@ -61,11 +61,7 @@ const validateResourceMapperValue = (
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.valid) {
|
if (!validationResult.valid) {
|
||||||
if (!resourceMapperField.ignoreTypeMismatchErrors) {
|
|
||||||
return { ...validationResult, fieldName: key };
|
return { ...validationResult, fieldName: key };
|
||||||
} else {
|
|
||||||
paramValues[key] = resolvedValue;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// If it's valid, set the casted value
|
// If it's valid, set the casted value
|
||||||
paramValues[key] = validationResult.newValue;
|
paramValues[key] = validationResult.newValue;
|
||||||
|
|
|
@ -11,11 +11,16 @@ import { useRootStore } from '@/stores/root.store';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { renderComponent } from '@/__tests__/render';
|
import { renderComponent } from '@/__tests__/render';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
vi.mock('@/composables/useToast', () => ({
|
vi.mock('@/composables/useToast', () => ({
|
||||||
useToast: vi.fn(),
|
useToast: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => ({
|
||||||
|
useTelemetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/stores/settings.store', () => ({
|
vi.mock('@/stores/settings.store', () => ({
|
||||||
useSettingsStore: vi.fn(),
|
useSettingsStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
@ -55,7 +60,17 @@ const assertUserCanClaimCredits = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertUserClaimedCredits = () => {
|
const assertUserClaimedCredits = () => {
|
||||||
expect(screen.getByText('Claimed 100 free OpenAI API credits')).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Claimed 100 free OpenAI API credits! Please note these free credits are only for the following models:',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('FreeAiCreditsCallout', () => {
|
describe('FreeAiCreditsCallout', () => {
|
||||||
|
@ -86,7 +101,7 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
(usePostHog as any).mockReturnValue({
|
(usePostHog as any).mockReturnValue({
|
||||||
isFeatureEnabled: vi.fn().mockReturnValue(true),
|
getVariant: vi.fn().mockReturnValue('variant'),
|
||||||
});
|
});
|
||||||
|
|
||||||
(useProjectsStore as any).mockReturnValue({
|
(useProjectsStore as any).mockReturnValue({
|
||||||
|
@ -100,6 +115,10 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
(useToast as any).mockReturnValue({
|
(useToast as any).mockReturnValue({
|
||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(useTelemetry as any).mockReturnValue({
|
||||||
|
track: vi.fn(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should shows the claim callout when the user can claim credits', () => {
|
it('should shows the claim callout when the user can claim credits', () => {
|
||||||
|
@ -120,6 +139,7 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
await fireEvent.click(claimButton);
|
await fireEvent.click(claimButton);
|
||||||
|
|
||||||
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
|
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith('User claimed OpenAI credits');
|
||||||
assertUserClaimedCredits();
|
assertUserClaimedCredits();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -150,7 +170,7 @@ describe('FreeAiCreditsCallout', () => {
|
||||||
|
|
||||||
it('should not be able to claim credits if user it is not in experiment', async () => {
|
it('should not be able to claim credits if user it is not in experiment', async () => {
|
||||||
(usePostHog as any).mockReturnValue({
|
(usePostHog as any).mockReturnValue({
|
||||||
isFeatureEnabled: vi.fn().mockReturnValue(false),
|
getVariant: vi.fn().mockReturnValue('control'),
|
||||||
});
|
});
|
||||||
|
|
||||||
renderComponent(FreeAiCreditsCallout);
|
renderComponent(FreeAiCreditsCallout);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { AI_CREDITS_EXPERIMENT } from '@/constants';
|
import { AI_CREDITS_EXPERIMENT } from '@/constants';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
@ -27,11 +28,12 @@ const showSuccessCallout = ref(false);
|
||||||
const claimingCredits = ref(false);
|
const claimingCredits = ref(false);
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const postHogStore = usePostHog();
|
const posthogStore = usePostHog();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
@ -57,7 +59,7 @@ const userCanClaimOpenAiCredits = computed(() => {
|
||||||
return (
|
return (
|
||||||
settingsStore.isAiCreditsEnabled &&
|
settingsStore.isAiCreditsEnabled &&
|
||||||
activeNodeHasOpenAiApiCredential.value &&
|
activeNodeHasOpenAiApiCredential.value &&
|
||||||
postHogStore.isFeatureEnabled(AI_CREDITS_EXPERIMENT.name) &&
|
posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === AI_CREDITS_EXPERIMENT.variant &&
|
||||||
!userHasOpenAiCredentialAlready.value &&
|
!userHasOpenAiCredentialAlready.value &&
|
||||||
!userHasClaimedAiCreditsAlready.value
|
!userHasClaimedAiCreditsAlready.value
|
||||||
);
|
);
|
||||||
|
@ -73,6 +75,8 @@ const onClaimCreditsClicked = async () => {
|
||||||
usersStore.currentUser.settings.userClaimedAiCredits = true;
|
usersStore.currentUser.settings.userClaimedAiCredits = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
telemetry.track('User claimed OpenAI credits');
|
||||||
|
|
||||||
showSuccessCallout.value = true;
|
showSuccessCallout.value = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(
|
toast.showError(
|
||||||
|
@ -108,11 +112,16 @@ const onClaimCreditsClicked = async () => {
|
||||||
</template>
|
</template>
|
||||||
</n8n-callout>
|
</n8n-callout>
|
||||||
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
|
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
|
||||||
|
<n8n-text>
|
||||||
{{
|
{{
|
||||||
i18n.baseText('freeAi.credits.callout.success.title', {
|
i18n.baseText('freeAi.credits.callout.success.title.part1', {
|
||||||
interpolate: { credits: settingsStore.aiCreditsQuota },
|
interpolate: { credits: settingsStore.aiCreditsQuota },
|
||||||
})
|
})
|
||||||
}}
|
}}</n8n-text
|
||||||
|
>
|
||||||
|
<n8n-text :bold="true">
|
||||||
|
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
|
||||||
|
>
|
||||||
</n8n-callout>
|
</n8n-callout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -63,7 +63,6 @@ const state = reactive({
|
||||||
value: {},
|
value: {},
|
||||||
matchingColumns: [] as string[],
|
matchingColumns: [] as string[],
|
||||||
schema: [] as ResourceMapperField[],
|
schema: [] as ResourceMapperField[],
|
||||||
ignoreTypeMismatchErrors: false,
|
|
||||||
attemptToConvertTypes: false,
|
attemptToConvertTypes: false,
|
||||||
// This should always be true if `showTypeConversionOptions` is provided
|
// This should always be true if `showTypeConversionOptions` is provided
|
||||||
// It's used to avoid accepting any value as string without casting it
|
// It's used to avoid accepting any value as string without casting it
|
||||||
|
@ -664,23 +663,6 @@ defineExpose({
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<ParameterInputFull
|
|
||||||
:parameter="{
|
|
||||||
name: 'ignoreTypeMismatchErrors',
|
|
||||||
type: 'boolean',
|
|
||||||
displayName: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.displayName'),
|
|
||||||
default: false,
|
|
||||||
description: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.description'),
|
|
||||||
}"
|
|
||||||
:path="props.path + '.ignoreTypeMismatchErrors'"
|
|
||||||
:value="state.paramValue.ignoreTypeMismatchErrors"
|
|
||||||
@update="
|
|
||||||
(x: IUpdateInformation<NodeParameterValueType>) => {
|
|
||||||
state.paramValue.ignoreTypeMismatchErrors = x.value as boolean;
|
|
||||||
emitValueChanged();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -29,7 +29,10 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = `
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {},
|
"connections": {},
|
||||||
"pinData": {}
|
"pinData": {},
|
||||||
|
"meta": {
|
||||||
|
"instanceId": ""
|
||||||
|
}
|
||||||
}",
|
}",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
@ -64,7 +67,10 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = `
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {},
|
"connections": {},
|
||||||
"pinData": {}
|
"pinData": {},
|
||||||
|
"meta": {
|
||||||
|
"instanceId": ""
|
||||||
|
}
|
||||||
}",
|
}",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
|
@ -1887,6 +1887,12 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
async function copyNodes(ids: string[]) {
|
async function copyNodes(ids: string[]) {
|
||||||
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
|
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
|
||||||
|
|
||||||
|
workflowData.meta = {
|
||||||
|
...workflowData.meta,
|
||||||
|
...workflowsStore.workflow.meta,
|
||||||
|
instanceId: rootStore.instanceId,
|
||||||
|
};
|
||||||
|
|
||||||
await clipboard.copy(JSON.stringify(workflowData, null, 2));
|
await clipboard.copy(JSON.stringify(workflowData, null, 2));
|
||||||
|
|
||||||
telemetry.track('User copied nodes', {
|
telemetry.track('User copied nodes', {
|
||||||
|
|
|
@ -1600,8 +1600,6 @@
|
||||||
"resourceMapper.staleDataWarning.notice": "Refresh to see the updated fields",
|
"resourceMapper.staleDataWarning.notice": "Refresh to see the updated fields",
|
||||||
"resourceMapper.attemptToConvertTypes.displayName": "Attempt to convert types",
|
"resourceMapper.attemptToConvertTypes.displayName": "Attempt to convert types",
|
||||||
"resourceMapper.attemptToConvertTypes.description": "Attempt to convert types when mapping fields",
|
"resourceMapper.attemptToConvertTypes.description": "Attempt to convert types when mapping fields",
|
||||||
"resourceMapper.ignoreTypeMismatchErrors.displayName": "Ignore type mismatch errors",
|
|
||||||
"resourceMapper.ignoreTypeMismatchErrors.description": "Whether type mismatches should be ignored, rather than returning an Error",
|
|
||||||
"runData.openSubExecution": "Inspect Sub-Execution {id}",
|
"runData.openSubExecution": "Inspect Sub-Execution {id}",
|
||||||
"runData.openParentExecution": "Inspect Parent Execution {id}",
|
"runData.openParentExecution": "Inspect Parent Execution {id}",
|
||||||
"runData.emptyItemHint": "This is an item, but it's empty.",
|
"runData.emptyItemHint": "This is an item, but it's empty.",
|
||||||
|
@ -2846,7 +2844,8 @@
|
||||||
"testDefinition.deleteTest": "Delete Test",
|
"testDefinition.deleteTest": "Delete Test",
|
||||||
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
|
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
|
||||||
"freeAi.credits.callout.claim.button.label": "Claim credits",
|
"freeAi.credits.callout.claim.button.label": "Claim credits",
|
||||||
"freeAi.credits.callout.success.title": "Claimed {credits} free OpenAI API credits",
|
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",
|
||||||
|
"freeAi.credits.callout.success.title.part2": "gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest",
|
||||||
"freeAi.credits.credentials.edit": "This is a managed credential and cannot be edited.",
|
"freeAi.credits.credentials.edit": "This is a managed credential and cannot be edited.",
|
||||||
"freeAi.credits.showError.claim.title": "Free AI credits",
|
"freeAi.credits.showError.claim.title": "Free AI credits",
|
||||||
"freeAi.credits.showError.claim.message": "Enable to claim credits"
|
"freeAi.credits.showError.claim.message": "Enable to claim credits"
|
||||||
|
|
|
@ -118,6 +118,11 @@ const LazyNodeDetailsView = defineAsyncComponent(
|
||||||
async () => await import('@/components/NodeDetailsView.vue'),
|
async () => await import('@/components/NodeDetailsView.vue'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LazySetupWorkflowCredentialsButton = defineAsyncComponent(
|
||||||
|
async () =>
|
||||||
|
await import('@/components/SetupWorkflowCredentialsButton/SetupWorkflowCredentialsButton.vue'),
|
||||||
|
);
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
@ -1696,6 +1701,9 @@ onBeforeUnmount(() => {
|
||||||
@viewport-change="onViewportChange"
|
@viewport-change="onViewportChange"
|
||||||
@drag-and-drop="onDragAndDrop"
|
@drag-and-drop="onDragAndDrop"
|
||||||
>
|
>
|
||||||
|
<Suspense>
|
||||||
|
<LazySetupWorkflowCredentialsButton :class="$style.setupCredentialsButtonWrapper" />
|
||||||
|
</Suspense>
|
||||||
<div v-if="!isCanvasReadOnly" :class="$style.executionButtons">
|
<div v-if="!isCanvasReadOnly" :class="$style.executionButtons">
|
||||||
<CanvasRunWorkflowButton
|
<CanvasRunWorkflowButton
|
||||||
v-if="isRunWorkflowButtonVisible"
|
v-if="isRunWorkflowButtonVisible"
|
||||||
|
@ -1804,6 +1812,12 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setupCredentialsButtonWrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--spacing-s);
|
||||||
|
top: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
.readOnlyEnvironmentNotification {
|
.readOnlyEnvironmentNotification {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
|
|
|
@ -10,6 +10,214 @@ import type {
|
||||||
Icon,
|
Icon,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
const regions = [
|
||||||
|
{
|
||||||
|
name: 'africa-south1',
|
||||||
|
displayName: 'Africa',
|
||||||
|
location: 'Johannesburg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-east1',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Changhua County',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-east2',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Hong Kong',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-northeast1',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Tokyo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-northeast2',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Osaka',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-northeast3',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Seoul',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-south1',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Mumbai',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-south2',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Delhi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-southeast1',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Jurong West',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'asia-southeast2',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Jakarta',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'australia-southeast1',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Sydney',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'australia-southeast2',
|
||||||
|
displayName: 'Asia Pacific',
|
||||||
|
location: 'Melbourne',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-central2',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Warsaw',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-north1',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Hamina',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-southwest1',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Madrid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west1',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'St. Ghislain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west10',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Berlin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west12',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Turin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west2',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'London',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west3',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Frankfurt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west4',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Eemshaven',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west6',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Zurich',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west8',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Milan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'europe-west9',
|
||||||
|
displayName: 'Europe',
|
||||||
|
location: 'Paris',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'me-central1',
|
||||||
|
displayName: 'Middle East',
|
||||||
|
location: 'Doha',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'me-central2',
|
||||||
|
displayName: 'Middle East',
|
||||||
|
location: 'Dammam',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'me-west1',
|
||||||
|
displayName: 'Middle East',
|
||||||
|
location: 'Tel Aviv',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'northamerica-northeast1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Montréal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'northamerica-northeast2',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Toronto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'northamerica-south1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Queretaro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'southamerica-east1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Osasco',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'southamerica-west1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Santiago',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-central1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Council Bluffs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-east1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Moncks Corner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-east4',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Ashburn',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-east5',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Columbus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-south1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Dallas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-west1',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'The Dalles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-west2',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Los Angeles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-west3',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Salt Lake City',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'us-west4',
|
||||||
|
displayName: 'Americas',
|
||||||
|
location: 'Las Vegas',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
export class GoogleApi implements ICredentialType {
|
export class GoogleApi implements ICredentialType {
|
||||||
name = 'googleApi';
|
name = 'googleApi';
|
||||||
|
|
||||||
|
@ -20,6 +228,18 @@ export class GoogleApi implements ICredentialType {
|
||||||
icon: Icon = 'file:icons/Google.svg';
|
icon: Icon = 'file:icons/Google.svg';
|
||||||
|
|
||||||
properties: INodeProperties[] = [
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Region',
|
||||||
|
name: 'region',
|
||||||
|
type: 'options',
|
||||||
|
options: regions.map((r) => ({
|
||||||
|
name: `${r.displayName} (${r.location}) - ${r.name}`,
|
||||||
|
value: r.name,
|
||||||
|
})),
|
||||||
|
default: 'us-central1',
|
||||||
|
description:
|
||||||
|
'The region where the Google Cloud service is located. This applies only to specific nodes, like the Google Vertex Chat Model',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Service Account Email',
|
displayName: 'Service Account Email',
|
||||||
name: 'email',
|
name: 'email',
|
||||||
|
|
|
@ -2710,7 +2710,6 @@ export type ResourceMapperValue = {
|
||||||
value: { [key: string]: string | number | boolean | null } | null;
|
value: { [key: string]: string | number | boolean | null } | null;
|
||||||
matchingColumns: string[];
|
matchingColumns: string[];
|
||||||
schema: ResourceMapperField[];
|
schema: ResourceMapperField[];
|
||||||
ignoreTypeMismatchErrors: boolean;
|
|
||||||
attemptToConvertTypes: boolean;
|
attemptToConvertTypes: boolean;
|
||||||
convertFieldsToString: boolean;
|
convertFieldsToString: boolean;
|
||||||
};
|
};
|
||||||
|
|
|
@ -26,6 +26,7 @@ import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IRunData,
|
IRunData,
|
||||||
ITaskData,
|
ITaskData,
|
||||||
|
IRun,
|
||||||
} from './Interfaces';
|
} from './Interfaces';
|
||||||
import { getNodeParameters } from './NodeHelpers';
|
import { getNodeParameters } from './NodeHelpers';
|
||||||
|
|
||||||
|
@ -470,3 +471,21 @@ export function generateNodesGraph(
|
||||||
|
|
||||||
return { nodeGraph, nameIndices, webhookNodeNames };
|
return { nodeGraph, nameIndices, webhookNodeNames };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractLastExecutedNodeCredentialData(
|
||||||
|
runData: IRun,
|
||||||
|
): null | { credentialId: string; credentialType: string } {
|
||||||
|
const nodeCredentials = runData?.data?.executionData?.nodeExecutionStack?.[0]?.node?.credentials;
|
||||||
|
|
||||||
|
if (!nodeCredentials) return null;
|
||||||
|
|
||||||
|
const credentialType = Object.keys(nodeCredentials)[0] ?? null;
|
||||||
|
|
||||||
|
if (!credentialType) return null;
|
||||||
|
|
||||||
|
const { id } = nodeCredentials[credentialType];
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return { credentialId: id, credentialType };
|
||||||
|
}
|
||||||
|
|
|
@ -3,11 +3,12 @@ import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
|
||||||
|
|
||||||
import { STICKY_NODE_TYPE } from '@/Constants';
|
import { STICKY_NODE_TYPE } from '@/Constants';
|
||||||
import { ApplicationError } from '@/errors';
|
import { ApplicationError } from '@/errors';
|
||||||
import type { IRunData } from '@/Interfaces';
|
import type { IRun, IRunData } from '@/Interfaces';
|
||||||
import { NodeConnectionType, type IWorkflowBase } from '@/Interfaces';
|
import { NodeConnectionType, type IWorkflowBase } from '@/Interfaces';
|
||||||
import * as nodeHelpers from '@/NodeHelpers';
|
import * as nodeHelpers from '@/NodeHelpers';
|
||||||
import {
|
import {
|
||||||
ANONYMIZATION_CHARACTER as CHAR,
|
ANONYMIZATION_CHARACTER as CHAR,
|
||||||
|
extractLastExecutedNodeCredentialData,
|
||||||
generateNodesGraph,
|
generateNodesGraph,
|
||||||
getDomainBase,
|
getDomainBase,
|
||||||
getDomainPath,
|
getDomainPath,
|
||||||
|
@ -885,6 +886,50 @@ describe('generateNodesGraph', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractLastExecutedNodeCredentialData', () => {
|
||||||
|
const cases: Array<[string, IRun]> = [
|
||||||
|
['no data', mock<IRun>({ data: {} })],
|
||||||
|
['no executionData', mock<IRun>({ data: { executionData: undefined } })],
|
||||||
|
[
|
||||||
|
'no nodeExecutionStack',
|
||||||
|
mock<IRun>({ data: { executionData: { nodeExecutionStack: undefined } } }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'no node',
|
||||||
|
mock<IRun>({
|
||||||
|
data: { executionData: { nodeExecutionStack: [{ node: undefined }] } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'no credentials',
|
||||||
|
mock<IRun>({
|
||||||
|
data: { executionData: { nodeExecutionStack: [{ node: { credentials: undefined } }] } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(cases)(
|
||||||
|
'should return credentialId and credentialsType with null if %s',
|
||||||
|
(_, runData) => {
|
||||||
|
expect(extractLastExecutedNodeCredentialData(runData)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should return correct credentialId and credentialsType when last node executed has credential', () => {
|
||||||
|
const runData = mock<IRun>({
|
||||||
|
data: {
|
||||||
|
executionData: {
|
||||||
|
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extractLastExecutedNodeCredentialData(runData)).toMatchObject(
|
||||||
|
expect.objectContaining({ credentialId: 'nhu-l8E4hX', credentialType: 'openAiApi' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
|
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
|
||||||
const firstId = idMaker();
|
const firstId = idMaker();
|
||||||
const secondId = idMaker();
|
const secondId = idMaker();
|
||||||
|
|
Loading…
Reference in a new issue