mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' into ado-2936-can-not-disable-mfa-with-recovery-code
This commit is contained in:
commit
a33e9f258d
4
.github/workflows/test-workflows.yml
vendored
4
.github/workflows/test-workflows.yml
vendored
|
@ -74,6 +74,8 @@ jobs:
|
|||
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
|
||||
SKIP_STATISTICS_EVENTS: true
|
||||
DB_SQLITE_POOL_SIZE: 4
|
||||
N8N_SENTRY_DSN: ${{secrets.CI_SENTRY_DSN}}
|
||||
|
||||
# -
|
||||
# name: Export credentials
|
||||
# if: always()
|
||||
|
@ -93,7 +95,7 @@ jobs:
|
|||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@v2.0.0
|
||||
if: failure()
|
||||
if: failure() && github.ref == 'refs/heads/master'
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
channel: '#alerts-build'
|
||||
|
|
40
CHANGELOG.md
40
CHANGELOG.md
|
@ -1,3 +1,43 @@
|
|||
# [1.70.0](https://github.com/n8n-io/n8n/compare/n8n@1.69.0...n8n@1.70.0) (2024-11-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **AI Agent Node:** Add binary message before scratchpad to prevent tool calling loops ([#11845](https://github.com/n8n-io/n8n/issues/11845)) ([5c80cb5](https://github.com/n8n-io/n8n/commit/5c80cb57cf709a1097a38e0394aad6fce5330eba))
|
||||
* CodeNodeEditor walk cannot read properties of null ([#11129](https://github.com/n8n-io/n8n/issues/11129)) ([d99e0a7](https://github.com/n8n-io/n8n/commit/d99e0a7c979a1ee96b2eea1b9011d5bce375289a))
|
||||
* **core:** Bring back execution data on the `executionFinished` push message ([#11821](https://github.com/n8n-io/n8n/issues/11821)) ([0313570](https://github.com/n8n-io/n8n/commit/03135702f18e750ba44840dccfec042270629a2b))
|
||||
* **core:** Correct invalid WS status code on removing connection ([#11901](https://github.com/n8n-io/n8n/issues/11901)) ([1d80225](https://github.com/n8n-io/n8n/commit/1d80225d26ba01f78934a455acdcca7b83be7205))
|
||||
* **core:** Don't use unbound context methods in code sandboxes ([#11914](https://github.com/n8n-io/n8n/issues/11914)) ([f6c0d04](https://github.com/n8n-io/n8n/commit/f6c0d045e9683cd04ee849f37b96697097c5b41d))
|
||||
* **core:** Fix broken execution query when using projectId ([#11852](https://github.com/n8n-io/n8n/issues/11852)) ([a061dbc](https://github.com/n8n-io/n8n/commit/a061dbca07ad686c563e85c56081bc1a7830259b))
|
||||
* **core:** Fix validation of items returned in the task runner ([#11897](https://github.com/n8n-io/n8n/issues/11897)) ([a535e88](https://github.com/n8n-io/n8n/commit/a535e88f1aec8fbbf2eb9397d38748f49773de2d))
|
||||
* **editor:** Add missing trigger waiting tooltip on new canvas ([#11918](https://github.com/n8n-io/n8n/issues/11918)) ([a8df221](https://github.com/n8n-io/n8n/commit/a8df221bfbb5428d93d03f539bcfdaf29ee20c21))
|
||||
* **editor:** Don't re-render input panel after node finishes executing ([#11813](https://github.com/n8n-io/n8n/issues/11813)) ([b3a99a2](https://github.com/n8n-io/n8n/commit/b3a99a2351079c37ed6d83f43920ba80f3832234))
|
||||
* **editor:** Fix AI assistant loading message layout ([#11819](https://github.com/n8n-io/n8n/issues/11819)) ([89b4807](https://github.com/n8n-io/n8n/commit/89b48072432753137b498c338af7777036fdde7a))
|
||||
* **editor:** Fix new canvas discovery tooltip position after adding github stars button ([#11898](https://github.com/n8n-io/n8n/issues/11898)) ([f4ab5c7](https://github.com/n8n-io/n8n/commit/f4ab5c7b9244b8fdde427c12c1a152fbaaba0c34))
|
||||
* **editor:** Fix node position not getting set when dragging selection on new canvas ([#11871](https://github.com/n8n-io/n8n/issues/11871)) ([595de81](https://github.com/n8n-io/n8n/commit/595de81c03b3e488ab41fb8d1d316c3db6a8372a))
|
||||
* **editor:** Restore workers view ([#11876](https://github.com/n8n-io/n8n/issues/11876)) ([3aa72f6](https://github.com/n8n-io/n8n/commit/3aa72f613f64c16d7dff67ffe66037894e45aa7c))
|
||||
* **editor:** Turn NPS survey into a modal and make sure it shows above the Ask AI button ([#11814](https://github.com/n8n-io/n8n/issues/11814)) ([ca169f3](https://github.com/n8n-io/n8n/commit/ca169f3f3455fa39ce9120b30d7b409bade6561e))
|
||||
* **editor:** Use `crypto.randomUUID()` to initialize node id if missing on new canvas ([#11873](https://github.com/n8n-io/n8n/issues/11873)) ([bc4857a](https://github.com/n8n-io/n8n/commit/bc4857a1b3d6ea389f11fb8246a1cee33b8a008e))
|
||||
* **n8n Form Node:** Duplicate popup in manual mode ([#11925](https://github.com/n8n-io/n8n/issues/11925)) ([2c34bf4](https://github.com/n8n-io/n8n/commit/2c34bf4ea6137fb0fb321969684ffa621da20fa3))
|
||||
* **n8n Form Node:** Redirect if completion page to trigger ([#11822](https://github.com/n8n-io/n8n/issues/11822)) ([1a8fb7b](https://github.com/n8n-io/n8n/commit/1a8fb7bdc428c6a23c8708e2dcf924f1f10b47a9))
|
||||
* **OpenAI Node:** Remove preview chatInput parameter for `Assistant:Messsage` operation ([#11825](https://github.com/n8n-io/n8n/issues/11825)) ([4dde287](https://github.com/n8n-io/n8n/commit/4dde287cde3af7c9c0e57248e96b8f1270da9332))
|
||||
* Retain execution data between partial executions (new flow) ([#11828](https://github.com/n8n-io/n8n/issues/11828)) ([3320436](https://github.com/n8n-io/n8n/commit/3320436a6fdf8472b3843b9fe8d4de7af7f5ef5c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add SharePoint credentials ([#11570](https://github.com/n8n-io/n8n/issues/11570)) ([05c6109](https://github.com/n8n-io/n8n/commit/05c61091db9bdd62fdcca910ead50d0bd512966a))
|
||||
* Add Zabbix credential only node ([#11489](https://github.com/n8n-io/n8n/issues/11489)) ([fbd1ecf](https://github.com/n8n-io/n8n/commit/fbd1ecfb29461fee393914bc200ec72c654d8944))
|
||||
* **AI Transform Node:** Support for drag and drop ([#11276](https://github.com/n8n-io/n8n/issues/11276)) ([2c252b0](https://github.com/n8n-io/n8n/commit/2c252b0b2d5282f4a87bce76f93c4c02dd8ff5e3))
|
||||
* **editor:** Drop `response` wrapper requirement from Subworkflow Tool output ([#11785](https://github.com/n8n-io/n8n/issues/11785)) ([cd3598a](https://github.com/n8n-io/n8n/commit/cd3598aaab6cefe58a4cb9df7d93fb501415e9d3))
|
||||
* **editor:** Improve node and edge bring-to-front mechanism on new canvas ([#11793](https://github.com/n8n-io/n8n/issues/11793)) ([b89ca9d](https://github.com/n8n-io/n8n/commit/b89ca9d482faa5cb542898f3973fb6e7c9a8437a))
|
||||
* **editor:** Make new canvas connections go underneath node when looping backwards ([#11833](https://github.com/n8n-io/n8n/issues/11833)) ([91d1bd8](https://github.com/n8n-io/n8n/commit/91d1bd8d333454f3971605df73c3703102d2a9e9))
|
||||
* **editor:** Make the left sidebar in Expressions editor draggable ([#11838](https://github.com/n8n-io/n8n/issues/11838)) ([a713b3e](https://github.com/n8n-io/n8n/commit/a713b3ed25feb1790412fc320cf41a0967635263))
|
||||
* **editor:** Migrate existing users to new canvas and set new canvas as default ([#11896](https://github.com/n8n-io/n8n/issues/11896)) ([caa7447](https://github.com/n8n-io/n8n/commit/caa744785a2cc5063a5fb9d269c0ea53ea432298))
|
||||
* **Slack Node:** Update wait for approval to use markdown ([#11754](https://github.com/n8n-io/n8n/issues/11754)) ([40dd02f](https://github.com/n8n-io/n8n/commit/40dd02f360d0d8752fe89c4304c18cac9858c530))
|
||||
|
||||
|
||||
|
||||
# [1.69.0](https://github.com/n8n-io/n8n/compare/n8n@1.68.0...n8n@1.69.0) (2024-11-20)
|
||||
|
||||
|
||||
|
|
|
@ -75,8 +75,13 @@ Cypress.Commands.add('signin', ({ email, password }) => {
|
|||
.then((response) => {
|
||||
Cypress.env('currentUserId', response.body.data.id);
|
||||
|
||||
// @TODO Remove this once the switcher is removed
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('NodeView.switcher.discovered', 'true'); // @TODO Remove this once the switcher is removed
|
||||
win.localStorage.setItem('NodeView.migrated', 'true');
|
||||
win.localStorage.setItem('NodeView.switcher.discovered.beta', 'true');
|
||||
|
||||
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
|
||||
win.localStorage.setItem('NodeView.version', nodeViewVersion ?? '1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,11 +20,6 @@ beforeEach(() => {
|
|||
win.localStorage.setItem('N8N_THEME', 'light');
|
||||
win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
|
||||
win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true');
|
||||
|
||||
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
|
||||
if (nodeViewVersion) {
|
||||
win.localStorage.setItem('NodeView.version', nodeViewVersion);
|
||||
}
|
||||
});
|
||||
|
||||
cy.intercept('GET', '/rest/settings', (req) => {
|
||||
|
|
|
@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh /
|
|||
|
||||
# Setup the Task Runner Launcher
|
||||
ARG TARGETPLATFORM
|
||||
ARG LAUNCHER_VERSION=0.3.0-rc
|
||||
ARG LAUNCHER_VERSION=0.6.0-rc
|
||||
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
|
||||
# Download, verify, then extract the launcher binary
|
||||
RUN \
|
||||
|
|
|
@ -24,7 +24,7 @@ RUN set -eux; \
|
|||
|
||||
# Setup the Task Runner Launcher
|
||||
ARG TARGETPLATFORM
|
||||
ARG LAUNCHER_VERSION=0.3.0-rc
|
||||
ARG LAUNCHER_VERSION=0.6.0-rc
|
||||
COPY n8n-task-runners.json /etc/n8n-task-runners.json
|
||||
# Download, verify, then extract the launcher binary
|
||||
RUN \
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.69.0",
|
||||
"version": "1.70.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -172,4 +172,5 @@ export interface FrontendSettings {
|
|||
blockFileAccessToN8nFiles: boolean;
|
||||
};
|
||||
betaFeatures: FrontendBetaFeatures[];
|
||||
virtualSchemaView: boolean;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3",
|
||||
"iconv-lite": "catalog:",
|
||||
"imap": "0.8.19",
|
||||
"quoted-printable": "1.0.1",
|
||||
"utf8": "3.0.0",
|
||||
|
|
|
@ -81,31 +81,20 @@ function getSandbox(
|
|||
const workflowMode = this.getMode();
|
||||
|
||||
const context = getSandboxContext.call(this, itemIndex);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.addInputData = this.addInputData;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.addOutputData = this.addOutputData;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.getInputConnectionData = this.getInputConnectionData;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.getInputData = this.getInputData;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.getNode = this.getNode;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.getExecutionCancelSignal = this.getExecutionCancelSignal;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.getNodeOutputs = this.getNodeOutputs;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.executeWorkflow = this.executeWorkflow;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.getWorkflowDataProxy = this.getWorkflowDataProxy;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
context.addInputData = this.addInputData.bind(this);
|
||||
context.addOutputData = this.addOutputData.bind(this);
|
||||
context.getInputConnectionData = this.getInputConnectionData.bind(this);
|
||||
context.getInputData = this.getInputData.bind(this);
|
||||
context.getNode = this.getNode.bind(this);
|
||||
context.getExecutionCancelSignal = this.getExecutionCancelSignal.bind(this);
|
||||
context.getNodeOutputs = this.getNodeOutputs.bind(this);
|
||||
context.executeWorkflow = this.executeWorkflow.bind(this);
|
||||
context.getWorkflowDataProxy = this.getWorkflowDataProxy.bind(this);
|
||||
context.logger = this.logger;
|
||||
|
||||
if (options?.addItems) {
|
||||
context.items = context.$input.all();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
|
||||
const sandbox = new JavaScriptSandbox(context, code, this.helpers, {
|
||||
resolver: vmResolver,
|
||||
|
|
|
@ -94,7 +94,7 @@ export class DocumentGithubLoader implements INodeType {
|
|||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
console.log('Supplying data for Github Document Loader');
|
||||
this.logger.debug('Supplying data for Github Document Loader');
|
||||
|
||||
const repository = this.getNodeParameter('repository', itemIndex) as string;
|
||||
const branch = this.getNodeParameter('branch', itemIndex) as string;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.69.0",
|
||||
"version": "1.70.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
@ -9,7 +9,7 @@ class HealthcheckServerConfig {
|
|||
host: string = '127.0.0.1';
|
||||
|
||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
||||
port: number = 5680;
|
||||
port: number = 5681;
|
||||
}
|
||||
|
||||
@Config
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
import { ValidationError } from '@/js-task-runner/errors/validation-error';
|
||||
import {
|
||||
validateRunForAllItemsOutput,
|
||||
validateRunForEachItemOutput,
|
||||
} from '@/js-task-runner/result-validation';
|
||||
|
||||
describe('result validation', () => {
|
||||
describe('validateRunForAllItemsOutput', () => {
|
||||
it('should throw an error if the output is not an object', () => {
|
||||
expect(() => {
|
||||
validateRunForAllItemsOutput(undefined);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw an error if the output is an array and at least one item has a non-n8n key', () => {
|
||||
expect(() => {
|
||||
validateRunForAllItemsOutput([{ json: {} }, { json: {}, unknownKey: {} }]);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
it('should not throw an error if the output is an array and all items are json wrapped', () => {
|
||||
expect(() => {
|
||||
validateRunForAllItemsOutput([{ json: {} }, { json: {} }, { json: {} }]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['binary', {}],
|
||||
['pairedItem', {}],
|
||||
['error', {}],
|
||||
])(
|
||||
'should not throw an error if the output item has %s key in addition to json',
|
||||
(key, value) => {
|
||||
expect(() => {
|
||||
validateRunForAllItemsOutput([{ json: {} }, { json: {}, [key]: value }]);
|
||||
}).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('should not throw an error if the output is an array and all items are not json wrapped', () => {
|
||||
expect(() => {
|
||||
validateRunForAllItemsOutput([
|
||||
{
|
||||
id: 1,
|
||||
name: 'test3',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'test4',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'test5',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw if json is not an object', () => {
|
||||
expect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
validateRunForAllItemsOutput([{ json: 1 } as any]);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRunForEachItemOutput', () => {
|
||||
const index = 0;
|
||||
|
||||
it('should throw an error if the output is not an object', () => {
|
||||
expect(() => {
|
||||
validateRunForEachItemOutput(undefined, index);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw an error if the output is an array', () => {
|
||||
expect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
validateRunForEachItemOutput([] as any, index);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw if json is not an object', () => {
|
||||
expect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
validateRunForEachItemOutput({ json: 1 } as any, index);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw an error if the output is an array and at least one item has a non-n8n key', () => {
|
||||
expect(() => {
|
||||
validateRunForEachItemOutput({ json: {}, unknownKey: {} }, index);
|
||||
}).toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['binary', {}],
|
||||
['pairedItem', {}],
|
||||
['error', {}],
|
||||
])(
|
||||
'should not throw an error if the output item has %s key in addition to json',
|
||||
(key, value) => {
|
||||
expect(() => {
|
||||
validateRunForEachItemOutput({ json: {}, [key]: value }, index);
|
||||
}).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
|
@ -9,7 +9,7 @@ export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', '
|
|||
function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
|
||||
for (const key in item) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
||||
if (REQUIRED_N8N_ITEM_KEYS.has(key)) return;
|
||||
if (REQUIRED_N8N_ITEM_KEYS.has(key)) continue;
|
||||
|
||||
throw new ValidationError({
|
||||
message: `Unknown top-level item key: ${key}`,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ApplicationError, ensureError } from 'n8n-workflow';
|
||||
import { ApplicationError, ensureError, randomInt } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { type MessageEvent, WebSocket } from 'ws';
|
||||
|
@ -42,8 +42,11 @@ export interface RPCCallObject {
|
|||
[name: string]: ((...args: unknown[]) => Promise<unknown>) | RPCCallObject;
|
||||
}
|
||||
|
||||
const VALID_TIME_MS = 1000;
|
||||
const VALID_EXTRA_MS = 100;
|
||||
const OFFER_VALID_TIME_MS = 5000;
|
||||
const OFFER_VALID_EXTRA_MS = 100;
|
||||
|
||||
/** Converts milliseconds to nanoseconds */
|
||||
const msToNs = (ms: number) => BigInt(ms * 1_000_000);
|
||||
|
||||
export interface TaskRunnerOpts extends BaseRunnerConfig {
|
||||
taskType: string;
|
||||
|
@ -167,16 +170,20 @@ export abstract class TaskRunner extends EventEmitter {
|
|||
(Object.values(this.openOffers).length + Object.values(this.runningTasks).length);
|
||||
|
||||
for (let i = 0; i < offersToSend; i++) {
|
||||
// Add a bit of randomness so that not all offers expire at the same time
|
||||
const validForInMs = OFFER_VALID_TIME_MS + randomInt(500);
|
||||
// Add a little extra time to account for latency
|
||||
const validUntil = process.hrtime.bigint() + msToNs(validForInMs + OFFER_VALID_EXTRA_MS);
|
||||
const offer: TaskOffer = {
|
||||
offerId: nanoid(),
|
||||
validUntil: process.hrtime.bigint() + BigInt((VALID_TIME_MS + VALID_EXTRA_MS) * 1_000_000), // Adding a little extra time to account for latency
|
||||
validUntil,
|
||||
};
|
||||
this.openOffers.set(offer.offerId, offer);
|
||||
this.send({
|
||||
type: 'runner:taskoffer',
|
||||
taskType: this.taskType,
|
||||
offerId: offer.offerId,
|
||||
validFor: VALID_TIME_MS,
|
||||
validFor: validForInMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "1.69.0",
|
||||
"version": "1.70.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
@ -4,7 +4,7 @@ import fs from 'fs';
|
|||
import { diff } from 'json-diff';
|
||||
import pick from 'lodash/pick';
|
||||
import type { IRun, ITaskData, IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||
import { ApplicationError, jsonParse } from 'n8n-workflow';
|
||||
import { ApplicationError, jsonParse, ErrorReporterProxy } from 'n8n-workflow';
|
||||
import os from 'os';
|
||||
import { sep } from 'path';
|
||||
import { Container } from 'typedi';
|
||||
|
@ -822,6 +822,11 @@ export class ExecuteBatch extends BaseCommand {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorReporterProxy.error(e, {
|
||||
extra: {
|
||||
workflowId: workflowData.id,
|
||||
},
|
||||
});
|
||||
executionResult.error = `Workflow failed to execute: ${(e as Error).message}`;
|
||||
executionResult.executionStatus = 'error';
|
||||
}
|
||||
|
|
|
@ -405,4 +405,11 @@ export const schema = {
|
|||
doc: 'Set this to 1 to enable the new partial execution logic by default.',
|
||||
},
|
||||
},
|
||||
|
||||
virtualSchemaView: {
|
||||
doc: 'Whether to display the virtualized schema view',
|
||||
format: Boolean,
|
||||
default: false,
|
||||
env: 'N8N_VIRTUAL_SCHEMA_VIEW',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -22,6 +22,7 @@ import { SharedWorkflow } from './shared-workflow';
|
|||
import { TagEntity } from './tag-entity';
|
||||
import { TestDefinition } from './test-definition.ee';
|
||||
import { TestMetric } from './test-metric.ee';
|
||||
import { TestRun } from './test-run.ee';
|
||||
import { User } from './user';
|
||||
import { Variables } from './variables';
|
||||
import { WebhookEntity } from './webhook-entity';
|
||||
|
@ -62,4 +63,5 @@ export const entities = {
|
|||
ProcessedData,
|
||||
TestDefinition,
|
||||
TestMetric,
|
||||
TestRun,
|
||||
};
|
||||
|
|
38
packages/cli/src/databases/entities/test-run.ee.ts
Normal file
38
packages/cli/src/databases/entities/test-run.ee.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Column, Entity, Index, ManyToOne, RelationId } from '@n8n/typeorm';
|
||||
|
||||
import {
|
||||
datetimeColumnType,
|
||||
jsonColumnType,
|
||||
WithTimestampsAndStringId,
|
||||
} from '@/databases/entities/abstract-entity';
|
||||
import { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
|
||||
type TestRunStatus = 'new' | 'running' | 'completed' | 'error';
|
||||
|
||||
export type AggregatedTestRunMetrics = Record<string, number | boolean>;
|
||||
|
||||
/**
|
||||
* Entity representing a Test Run.
|
||||
* It stores info about a specific run of a test, combining the test definition with the status and collected metrics
|
||||
*/
|
||||
@Entity()
|
||||
@Index(['testDefinition'])
|
||||
export class TestRun extends WithTimestampsAndStringId {
|
||||
@ManyToOne('TestDefinition', 'runs')
|
||||
testDefinition: TestDefinition;
|
||||
|
||||
@RelationId((testRun: TestRun) => testRun.testDefinition)
|
||||
testDefinitionId: string;
|
||||
|
||||
@Column('varchar')
|
||||
status: TestRunStatus;
|
||||
|
||||
@Column({ type: datetimeColumnType, nullable: true })
|
||||
runAt: Date | null;
|
||||
|
||||
@Column({ type: datetimeColumnType, nullable: true })
|
||||
completedAt: Date | null;
|
||||
|
||||
@Column(jsonColumnType, { nullable: true })
|
||||
metrics: AggregatedTestRunMetrics;
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
|
||||
|
||||
const testRunTableName = 'test_run';
|
||||
|
||||
export class CreateTestRun1732549866705 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(testRunTableName)
|
||||
.withColumns(
|
||||
column('id').varchar(36).primary.notNull,
|
||||
column('testDefinitionId').varchar(36).notNull,
|
||||
column('status').varchar().notNull,
|
||||
column('runAt').timestamp(),
|
||||
column('completedAt').timestamp(),
|
||||
column('metrics').json,
|
||||
)
|
||||
.withIndexOn('testDefinitionId')
|
||||
.withForeignKey('testDefinitionId', {
|
||||
tableName: 'test_definition',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(testRunTableName);
|
||||
}
|
||||
}
|
|
@ -72,6 +72,7 @@ import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/172
|
|||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||
|
||||
export const mysqlMigrations: Migration[] = [
|
||||
InitialMigration1588157391238,
|
||||
|
@ -146,4 +147,5 @@ export const mysqlMigrations: Migration[] = [
|
|||
AddDescriptionToTestDefinition1731404028106,
|
||||
MigrateTestDefinitionKeyToString1731582748663,
|
||||
CreateTestMetricTable1732271325258,
|
||||
CreateTestRun1732549866705,
|
||||
];
|
||||
|
|
|
@ -72,6 +72,7 @@ import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/172
|
|||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
InitialMigration1587669153312,
|
||||
|
@ -146,4 +147,5 @@ export const postgresMigrations: Migration[] = [
|
|||
AddDescriptionToTestDefinition1731404028106,
|
||||
MigrateTestDefinitionKeyToString1731582748663,
|
||||
CreateTestMetricTable1732271325258,
|
||||
CreateTestRun1732549866705,
|
||||
];
|
||||
|
|
|
@ -69,6 +69,7 @@ import { SeparateExecutionCreationFromStart1727427440136 } from '../common/17274
|
|||
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
|
@ -140,6 +141,7 @@ const sqliteMigrations: Migration[] = [
|
|||
AddDescriptionToTestDefinition1731404028106,
|
||||
MigrateTestDefinitionKeyToString1731582748663,
|
||||
CreateTestMetricTable1732271325258,
|
||||
CreateTestRun1732549866705,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
import type { AggregatedTestRunMetrics } from '@/databases/entities/test-run.ee';
|
||||
import { TestRun } from '@/databases/entities/test-run.ee';
|
||||
|
||||
@Service()
|
||||
export class TestRunRepository extends Repository<TestRun> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(TestRun, dataSource.manager);
|
||||
}
|
||||
|
||||
public async createTestRun(testDefinitionId: string) {
|
||||
const testRun = this.create({
|
||||
status: 'new',
|
||||
testDefinition: { id: testDefinitionId },
|
||||
});
|
||||
|
||||
return await this.save(testRun);
|
||||
}
|
||||
|
||||
public async markAsRunning(id: string) {
|
||||
return await this.update(id, { status: 'running', runAt: new Date() });
|
||||
}
|
||||
|
||||
public async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
|
||||
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
|
||||
}
|
||||
}
|
13
packages/cli/src/evaluation/metric.schema.ts
Normal file
13
packages/cli/src/evaluation/metric.schema.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const testMetricCreateRequestBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const testMetricPatchRequestBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
})
|
||||
.strict();
|
130
packages/cli/src/evaluation/metrics.controller.ts
Normal file
130
packages/cli/src/evaluation/metrics.controller.ts
Normal file
|
@ -0,0 +1,130 @@
|
|||
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/metric.schema';
|
||||
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||
|
||||
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,
|
||||
) {}
|
||||
|
||||
// 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');
|
||||
|
||||
await this.testMetricRepository.update(metricId, bodyParseResult.data);
|
||||
|
||||
// Respond with the updated metric
|
||||
return await this.testMetricRepository.findOneBy({ id: metricId });
|
||||
}
|
||||
|
||||
@Delete('/:testDefinitionId/metrics/:id')
|
||||
async delete(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');
|
||||
|
||||
await this.testMetricRepository.delete(metricId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
|
@ -33,3 +33,33 @@ export declare namespace TestDefinitionsRequest {
|
|||
|
||||
type Run = AuthenticatedRequest<RouteParams.TestId>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /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>;
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"conditions": [
|
||||
{
|
||||
"id": "9d3abc8d-3270-4bec-9a59-82622d5dbb5a",
|
||||
"leftValue": "={{ $json.actual.Code[0].data.main[0].length }}",
|
||||
"leftValue": "={{ $json.newExecution.Code[0].data.main[0].length }}",
|
||||
"rightValue": 3,
|
||||
"operator": {
|
||||
"type": "number",
|
||||
|
@ -30,7 +30,7 @@
|
|||
},
|
||||
{
|
||||
"id": "894ce84b-13a4-4415-99c0-0c25182903bb",
|
||||
"leftValue": "={{ $json.actual.Code[0].data.main[0][0].json.random }}",
|
||||
"leftValue": "={{ $json.newExecution.Code[0].data.main[0][0].json.random }}",
|
||||
"rightValue": 0.7,
|
||||
"operator": {
|
||||
"type": "number",
|
||||
|
|
|
@ -2,13 +2,16 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
|
|||
import { stringify } from 'flatted';
|
||||
import { readFileSync } from 'fs';
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IRun } from 'n8n-workflow';
|
||||
import path from 'path';
|
||||
|
||||
import type { ActiveExecutions } from '@/active-executions';
|
||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
import type { TestRun } from '@/databases/entities/test-run.ee';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
|
||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
|
@ -18,6 +21,10 @@ const wfUnderTestJson = JSON.parse(
|
|||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const wfEvaluationJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const executionDataJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
@ -41,11 +48,22 @@ const executionMocks = [
|
|||
}),
|
||||
];
|
||||
|
||||
function mockExecutionData() {
|
||||
return mock<IRun>({
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('TestRunnerService', () => {
|
||||
const executionRepository = mock<ExecutionRepository>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
const workflowRunner = mock<WorkflowRunner>();
|
||||
const activeExecutions = mock<ActiveExecutions>();
|
||||
const testRunRepository = mock<TestRunRepository>();
|
||||
|
||||
beforeEach(() => {
|
||||
const executionsQbMock = mockDeep<SelectQueryBuilder<ExecutionEntity>>({
|
||||
|
@ -60,6 +78,16 @@ describe('TestRunnerService', () => {
|
|||
executionRepository.findOne
|
||||
.calledWith(expect.objectContaining({ where: { id: 'some-execution-id-2' } }))
|
||||
.mockResolvedValueOnce(executionMocks[1]);
|
||||
|
||||
testRunRepository.createTestRun.mockResolvedValue(mock<TestRun>({ id: 'test-run-id' }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
activeExecutions.getPostExecutePromise.mockClear();
|
||||
workflowRunner.run.mockClear();
|
||||
testRunRepository.createTestRun.mockClear();
|
||||
testRunRepository.markAsRunning.mockClear();
|
||||
testRunRepository.markAsCompleted.mockClear();
|
||||
});
|
||||
|
||||
test('should create an instance of TestRunnerService', async () => {
|
||||
|
@ -68,6 +96,7 @@ describe('TestRunnerService', () => {
|
|||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
);
|
||||
|
||||
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||
|
@ -79,6 +108,7 @@ describe('TestRunnerService', () => {
|
|||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -86,12 +116,18 @@ describe('TestRunnerService', () => {
|
|||
...wfUnderTestJson,
|
||||
});
|
||||
|
||||
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||
id: 'evaluation-workflow-id',
|
||||
...wfEvaluationJson,
|
||||
});
|
||||
|
||||
workflowRunner.run.mockResolvedValue('test-execution-id');
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -99,4 +135,97 @@ describe('TestRunnerService', () => {
|
|||
expect(executionRepository.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should run both workflow under test and evaluation workflow', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
);
|
||||
|
||||
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-2')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
// Mock executions of evaluation workflow
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
}),
|
||||
);
|
||||
|
||||
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: 'evaluation',
|
||||
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(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { parse } from 'flatted';
|
||||
import type { IPinData, IRun, IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IPinData,
|
||||
IRun,
|
||||
IRunData,
|
||||
IWorkflowExecutionDataProcess,
|
||||
} from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
|
@ -9,8 +15,10 @@ import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
|||
import type { User } from '@/databases/entities/user';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
|
||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import type { IExecutionResponse } from '@/interfaces';
|
||||
import { getRunData } from '@/workflow-execute-additional-data';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
/**
|
||||
|
@ -18,7 +26,8 @@ import { WorkflowRunner } from '@/workflow-runner';
|
|||
* It uses the test definitions to find
|
||||
* past executions, creates pin data from them,
|
||||
* and runs the workflow-under-test with the pin data.
|
||||
* TODO: Evaluation workflows
|
||||
* After the workflow-under-test finishes, it runs the evaluation workflow
|
||||
* with the original and new run data.
|
||||
* TODO: Node pinning
|
||||
* TODO: Collect metrics
|
||||
*/
|
||||
|
@ -29,17 +38,16 @@ export class TestRunnerService {
|
|||
private readonly workflowRunner: WorkflowRunner,
|
||||
private readonly executionRepository: ExecutionRepository,
|
||||
private readonly activeExecutions: ActiveExecutions,
|
||||
private readonly testRunRepository: TestRunRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Extracts the execution data from the past execution.
|
||||
* Creates a pin data object from the past execution data
|
||||
* for the given workflow.
|
||||
* For now, it only pins trigger nodes.
|
||||
*/
|
||||
private createPinDataFromExecution(
|
||||
workflow: WorkflowEntity,
|
||||
execution: ExecutionEntity,
|
||||
): IPinData {
|
||||
private createTestDataFromExecution(workflow: WorkflowEntity, execution: ExecutionEntity) {
|
||||
const executionData = parse(execution.executionData.data) as IExecutionResponse['data'];
|
||||
|
||||
const triggerNodes = workflow.nodes.filter((node) => /trigger$/i.test(node.type));
|
||||
|
@ -53,7 +61,7 @@ export class TestRunnerService {
|
|||
}
|
||||
}
|
||||
|
||||
return pinData;
|
||||
return { pinData, executionData };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -65,6 +73,7 @@ export class TestRunnerService {
|
|||
testCasePinData: IPinData,
|
||||
userId: string,
|
||||
): Promise<IRun | undefined> {
|
||||
// Prepare the data to run the workflow
|
||||
const data: IWorkflowExecutionDataProcess = {
|
||||
executionMode: 'evaluation',
|
||||
runData: {},
|
||||
|
@ -78,12 +87,55 @@ export class TestRunnerService {
|
|||
const executionId = await this.workflowRunner.run(data);
|
||||
assert(executionId);
|
||||
|
||||
// Wait for the workflow to finish execution
|
||||
// Wait for the execution to finish
|
||||
const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
|
||||
|
||||
return await executePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the evaluation workflow with the expected and actual run data.
|
||||
*/
|
||||
private async runTestCaseEvaluation(
|
||||
evaluationWorkflow: WorkflowEntity,
|
||||
expectedData: IRunData,
|
||||
actualData: IRunData,
|
||||
) {
|
||||
// Prepare the evaluation wf input data.
|
||||
// Provide both the expected data and the actual data
|
||||
const evaluationInputData = {
|
||||
json: {
|
||||
originalExecution: expectedData,
|
||||
newExecution: actualData,
|
||||
},
|
||||
};
|
||||
|
||||
// Prepare the data to run the evaluation workflow
|
||||
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
|
||||
|
||||
data.executionMode = 'evaluation';
|
||||
|
||||
// Trigger the evaluation workflow
|
||||
const executionId = await this.workflowRunner.run(data);
|
||||
assert(executionId);
|
||||
|
||||
// Wait for the execution to finish
|
||||
const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
|
||||
|
||||
return await executePromise;
|
||||
}
|
||||
|
||||
private extractEvaluationResult(execution: IRun): IDataObject {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow');
|
||||
|
||||
// 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 ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new test run for the given test definition.
|
||||
*/
|
||||
|
@ -91,6 +143,13 @@ export class TestRunnerService {
|
|||
const workflow = await this.workflowRepository.findById(test.workflowId);
|
||||
assert(workflow, 'Workflow not found');
|
||||
|
||||
const evaluationWorkflow = await this.workflowRepository.findById(test.evaluationWorkflowId);
|
||||
assert(evaluationWorkflow, 'Evaluation workflow not found');
|
||||
|
||||
// 0. Create new Test Run
|
||||
const testRun = await this.testRunRepository.createTestRun(test.id);
|
||||
assert(testRun, 'Unable to create a test run');
|
||||
|
||||
// 1. Make test cases from previous executions
|
||||
|
||||
// Select executions with the annotation tag and workflow ID of the test.
|
||||
|
@ -105,25 +164,54 @@ export class TestRunnerService {
|
|||
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
|
||||
.getMany();
|
||||
|
||||
// 2. Run the test cases
|
||||
// 2. Run over all the test cases
|
||||
|
||||
await this.testRunRepository.markAsRunning(testRun.id);
|
||||
|
||||
const metrics = [];
|
||||
|
||||
for (const { id: pastExecutionId } of pastExecutions) {
|
||||
// Fetch past execution with data
|
||||
const pastExecution = await this.executionRepository.findOne({
|
||||
where: { id: pastExecutionId },
|
||||
relations: ['executionData', 'metadata'],
|
||||
});
|
||||
assert(pastExecution, 'Execution not found');
|
||||
|
||||
const pinData = this.createPinDataFromExecution(workflow, pastExecution);
|
||||
const testData = this.createTestDataFromExecution(workflow, pastExecution);
|
||||
const { pinData, executionData } = testData;
|
||||
|
||||
// Run the test case and wait for it to finish
|
||||
const execution = await this.runTestCase(workflow, pinData, user.id);
|
||||
const testCaseExecution = await this.runTestCase(workflow, pinData, user.id);
|
||||
|
||||
if (!execution) {
|
||||
// In case of a permission check issue, the test case execution will be undefined.
|
||||
// Skip them and continue with the next test case
|
||||
if (!testCaseExecution) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: 2.3 Collect the run data
|
||||
// Collect the results of the test case execution
|
||||
const testCaseRunData = testCaseExecution.data.resultData.runData;
|
||||
|
||||
// Get the original runData from the test case execution data
|
||||
const originalRunData = executionData.resultData.runData;
|
||||
|
||||
// Run the evaluation workflow with the original and new run data
|
||||
const evalExecution = await this.runTestCaseEvaluation(
|
||||
evaluationWorkflow,
|
||||
originalRunData,
|
||||
testCaseRunData,
|
||||
);
|
||||
assert(evalExecution);
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
metrics.push(this.extractEvaluationResult(evalExecution));
|
||||
}
|
||||
|
||||
// TODO: 3. Aggregate the results
|
||||
// Now we just set success to true if all the test cases passed
|
||||
const aggregatedMetrics = { success: metrics.every((metric) => metric.success) };
|
||||
|
||||
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -163,6 +163,8 @@ export class TaskRunnerServer {
|
|||
authEndpoint,
|
||||
send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)),
|
||||
);
|
||||
|
||||
this.app.get('/healthz', (_, res) => res.send({ status: 'ok' }));
|
||||
}
|
||||
|
||||
private handleUpgradeRequest = (
|
||||
|
|
|
@ -64,6 +64,7 @@ import '@/executions/executions.controller';
|
|||
import '@/external-secrets/external-secrets.controller.ee';
|
||||
import '@/license/license.controller';
|
||||
import '@/evaluation/test-definitions.controller.ee';
|
||||
import '@/evaluation/metrics.controller';
|
||||
import '@/workflows/workflow-history/workflow-history.controller.ee';
|
||||
import '@/workflows/workflows.controller';
|
||||
|
||||
|
|
|
@ -231,6 +231,7 @@ export class FrontendService {
|
|||
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
||||
},
|
||||
betaFeatures: this.frontendConfig.betaFeatures,
|
||||
virtualSchemaView: config.getEnv('virtualSchemaView'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -317,8 +317,9 @@ export class WorkflowRunner {
|
|||
workflowExecution = workflowExecute.runPartialWorkflow2(
|
||||
workflow,
|
||||
data.runData,
|
||||
data.destinationNode,
|
||||
data.pinData,
|
||||
data.dirtyNodeNames,
|
||||
data.destinationNode,
|
||||
);
|
||||
} else {
|
||||
workflowExecution = workflowExecute.runPartialWorkflow(
|
||||
|
|
|
@ -89,7 +89,13 @@ export class WorkflowExecutionService {
|
|||
}
|
||||
|
||||
async executeManually(
|
||||
{ workflowData, runData, startNodes, destinationNode }: WorkflowRequest.ManualRunPayload,
|
||||
{
|
||||
workflowData,
|
||||
runData,
|
||||
startNodes,
|
||||
destinationNode,
|
||||
dirtyNodeNames,
|
||||
}: WorkflowRequest.ManualRunPayload,
|
||||
user: User,
|
||||
pushRef?: string,
|
||||
partialExecutionVersion?: string,
|
||||
|
@ -137,6 +143,7 @@ export class WorkflowExecutionService {
|
|||
workflowData,
|
||||
userId: user.id,
|
||||
partialExecutionVersion: partialExecutionVersion ?? '0',
|
||||
dirtyNodeNames,
|
||||
};
|
||||
|
||||
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
||||
|
|
|
@ -22,6 +22,7 @@ export declare namespace WorkflowRequest {
|
|||
runData: IRunData;
|
||||
startNodes?: StartNodeData[];
|
||||
destinationNode?: string;
|
||||
dirtyNodeNames?: string[];
|
||||
};
|
||||
|
||||
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||
<link
|
||||
href='http://fonts.googleapis.com/css?family=Open+Sans'
|
||||
href='https://fonts.googleapis.com/css?family=Open+Sans'
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
/>
|
||||
|
@ -83,4 +83,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||
<link
|
||||
href='http://fonts.googleapis.com/css?family=Open+Sans'
|
||||
href='https://fonts.googleapis.com/css?family=Open+Sans'
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
/>
|
||||
|
@ -71,4 +71,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||
<link
|
||||
href='http://fonts.googleapis.com/css?family=Open+Sans'
|
||||
href='https://fonts.googleapis.com/css?family=Open+Sans'
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
/>
|
||||
|
@ -71,4 +71,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||
<link
|
||||
href='http://fonts.googleapis.com/css?family=Open+Sans'
|
||||
href='https://fonts.googleapis.com/css?family=Open+Sans'
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
/>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||
<link
|
||||
href='http://fonts.googleapis.com/css?family=Open+Sans'
|
||||
href='https://fonts.googleapis.com/css?family=Open+Sans'
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
/>
|
||||
|
@ -70,4 +70,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
381
packages/cli/test/integration/evaluation/metrics.api.test.ts
Normal file
381
packages/cli/test/integration/evaluation/metrics.api.test.ts
Normal file
|
@ -0,0 +1,381 @@
|
|||
import { Container } from 'typedi';
|
||||
|
||||
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
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: WorkflowEntity;
|
||||
let otherWorkflow: WorkflowEntity;
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -10,6 +10,7 @@ describe('TaskRunnerModule in internal mode', () => {
|
|||
const runnerConfig = Container.get(TaskRunnersConfig);
|
||||
runnerConfig.port = 0; // Random port
|
||||
runnerConfig.mode = 'internal';
|
||||
runnerConfig.enabled = true;
|
||||
const module = Container.get(TaskRunnerModule);
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
@ -1,21 +1,15 @@
|
|||
import { TaskRunnersConfig } from '@n8n/config';
|
||||
import Container from 'typedi';
|
||||
|
||||
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
|
||||
import { TaskBroker } from '@/runners/task-broker.service';
|
||||
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||
import { TaskRunnerServer } from '@/runners/task-runner-server';
|
||||
import { retryUntil } from '@test-integration/retry-until';
|
||||
import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server';
|
||||
|
||||
describe('TaskRunnerProcess', () => {
|
||||
const authToken = 'token';
|
||||
const runnerConfig = Container.get(TaskRunnersConfig);
|
||||
runnerConfig.enabled = true;
|
||||
runnerConfig.mode = 'internal';
|
||||
runnerConfig.authToken = authToken;
|
||||
runnerConfig.port = 0; // Use any port
|
||||
const taskRunnerServer = Container.get(TaskRunnerServer);
|
||||
|
||||
const { config, server: taskRunnerServer } = setupBrokerTestServer({
|
||||
mode: 'internal',
|
||||
});
|
||||
const runnerProcess = Container.get(TaskRunnerProcess);
|
||||
const taskBroker = Container.get(TaskBroker);
|
||||
const taskRunnerService = Container.get(TaskRunnerWsServer);
|
||||
|
@ -23,7 +17,7 @@ describe('TaskRunnerProcess', () => {
|
|||
beforeAll(async () => {
|
||||
await taskRunnerServer.start();
|
||||
// Set the port to the actually used port
|
||||
runnerConfig.port = taskRunnerServer.port;
|
||||
config.port = taskRunnerServer.port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server';
|
||||
|
||||
describe('TaskRunnerServer', () => {
|
||||
const { agent, server } = setupBrokerTestServer({
|
||||
authToken: 'token',
|
||||
mode: 'external',
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
describe('/healthz', () => {
|
||||
it('should return 200', async () => {
|
||||
await agent.get('/healthz').expect(200);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -75,6 +75,7 @@ const repositories = [
|
|||
'SharedWorkflow',
|
||||
'Tag',
|
||||
'TestDefinition',
|
||||
'TestMetric',
|
||||
'User',
|
||||
'Variables',
|
||||
'Webhook',
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { TaskRunnersConfig } from '@n8n/config';
|
||||
import request from 'supertest';
|
||||
import type TestAgent from 'supertest/lib/agent';
|
||||
import Container from 'typedi';
|
||||
|
||||
import { TaskRunnerServer } from '@/runners/task-runner-server';
|
||||
|
||||
export interface TestTaskBrokerServer {
|
||||
server: TaskRunnerServer;
|
||||
agent: TestAgent;
|
||||
config: TaskRunnersConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a Task Broker Server for testing purposes. The server needs
|
||||
* to be started and stopped manually.
|
||||
*
|
||||
* @example
|
||||
* const { server, agent, config } = setupBrokerTestServer();
|
||||
*
|
||||
* beforeAll(async () => await server.start());
|
||||
* afterAll(async () => await server.stop());
|
||||
*/
|
||||
export const setupBrokerTestServer = (
|
||||
config: Partial<TaskRunnersConfig> = {},
|
||||
): TestTaskBrokerServer => {
|
||||
const runnerConfig = Container.get(TaskRunnersConfig);
|
||||
Object.assign(runnerConfig, config);
|
||||
runnerConfig.enabled = true;
|
||||
runnerConfig.port = 0; // Use any port
|
||||
|
||||
const taskRunnerServer = Container.get(TaskRunnerServer);
|
||||
const agent = request.agent(taskRunnerServer.app);
|
||||
|
||||
return {
|
||||
server: taskRunnerServer,
|
||||
agent,
|
||||
config: runnerConfig,
|
||||
};
|
||||
};
|
|
@ -279,6 +279,7 @@ export const setupTestServer = ({
|
|||
break;
|
||||
|
||||
case 'evaluation':
|
||||
await import('@/evaluation/metrics.controller');
|
||||
await import('@/evaluation/test-definitions.controller.ee');
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "1.69.0",
|
||||
"version": "1.70.0",
|
||||
"description": "Core functionality of n8n",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
@ -47,6 +47,7 @@
|
|||
"fast-glob": "catalog:",
|
||||
"file-type": "16.5.4",
|
||||
"form-data": "catalog:",
|
||||
"iconv-lite": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"mime-types": "2.1.35",
|
||||
|
|
|
@ -28,6 +28,7 @@ import { createReadStream } from 'fs';
|
|||
import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Agent, type AgentOptions } from 'https';
|
||||
import iconv from 'iconv-lite';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import merge from 'lodash/merge';
|
||||
|
@ -674,14 +675,18 @@ function parseHeaderParameters(parameters: string[]): Record<string, string> {
|
|||
return parameters.reduce(
|
||||
(acc, param) => {
|
||||
const [key, value] = param.split('=');
|
||||
acc[key.toLowerCase().trim()] = decodeURIComponent(value);
|
||||
let decodedValue = decodeURIComponent(value).trim();
|
||||
if (decodedValue.startsWith('"') && decodedValue.endsWith('"')) {
|
||||
decodedValue = decodedValue.slice(1, -1);
|
||||
}
|
||||
acc[key.toLowerCase().trim()] = decodedValue;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}
|
||||
|
||||
function parseContentType(contentType?: string): IContentType | null {
|
||||
export function parseContentType(contentType?: string): IContentType | null {
|
||||
if (!contentType) {
|
||||
return null;
|
||||
}
|
||||
|
@ -694,22 +699,7 @@ function parseContentType(contentType?: string): IContentType | null {
|
|||
};
|
||||
}
|
||||
|
||||
function parseFileName(filename?: string): string | undefined {
|
||||
if (filename?.startsWith('"') && filename?.endsWith('"')) {
|
||||
return filename.slice(1, -1);
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc5987
|
||||
function parseFileNameStar(filename?: string): string | undefined {
|
||||
const [_encoding, _locale, content] = parseFileName(filename)?.split("'") ?? [];
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function parseContentDisposition(contentDisposition?: string): IContentDisposition | null {
|
||||
export function parseContentDisposition(contentDisposition?: string): IContentDisposition | null {
|
||||
if (!contentDisposition) {
|
||||
return null;
|
||||
}
|
||||
|
@ -724,11 +714,15 @@ function parseContentDisposition(contentDisposition?: string): IContentDispositi
|
|||
|
||||
const parsedParameters = parseHeaderParameters(parameters);
|
||||
|
||||
return {
|
||||
type,
|
||||
filename:
|
||||
parseFileNameStar(parsedParameters['filename*']) ?? parseFileName(parsedParameters.filename),
|
||||
};
|
||||
let { filename } = parsedParameters;
|
||||
const wildcard = parsedParameters['filename*'];
|
||||
if (wildcard) {
|
||||
// https://datatracker.ietf.org/doc/html/rfc5987
|
||||
const [_encoding, _locale, content] = wildcard?.split("'") ?? [];
|
||||
filename = content;
|
||||
}
|
||||
|
||||
return { type, filename };
|
||||
}
|
||||
|
||||
export function parseIncomingMessage(message: IncomingMessage) {
|
||||
|
@ -745,13 +739,13 @@ export function parseIncomingMessage(message: IncomingMessage) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function binaryToString(body: Buffer | Readable, encoding?: BufferEncoding) {
|
||||
const buffer = await binaryToBuffer(body);
|
||||
export async function binaryToString(body: Buffer | Readable, encoding?: string) {
|
||||
if (!encoding && body instanceof IncomingMessage) {
|
||||
parseIncomingMessage(body);
|
||||
encoding = body.encoding;
|
||||
}
|
||||
return buffer.toString(encoding);
|
||||
const buffer = await binaryToBuffer(body);
|
||||
return iconv.decode(buffer, encoding ?? 'utf-8');
|
||||
}
|
||||
|
||||
export async function proxyRequestToAxios(
|
||||
|
|
|
@ -46,7 +46,13 @@ describe('findStartNodes', () => {
|
|||
const node = createNodeData({ name: 'Basic Node' });
|
||||
const graph = new DirectedGraph().addNode(node);
|
||||
|
||||
const startNodes = findStartNodes({ graph, trigger: node, destination: node });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger: node,
|
||||
destination: node,
|
||||
pinData: {},
|
||||
runData: {},
|
||||
});
|
||||
|
||||
expect(startNodes.size).toBe(1);
|
||||
expect(startNodes).toContainEqual(node);
|
||||
|
@ -65,7 +71,13 @@ describe('findStartNodes', () => {
|
|||
|
||||
// if the trigger has no run data
|
||||
{
|
||||
const startNodes = findStartNodes({ graph, trigger, destination });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger,
|
||||
destination,
|
||||
pinData: {},
|
||||
runData: {},
|
||||
});
|
||||
|
||||
expect(startNodes.size).toBe(1);
|
||||
expect(startNodes).toContainEqual(trigger);
|
||||
|
@ -77,7 +89,13 @@ describe('findStartNodes', () => {
|
|||
[trigger.name]: [toITaskData([{ data: { value: 1 } }])],
|
||||
};
|
||||
|
||||
const startNodes = findStartNodes({ graph, trigger, destination, runData });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger,
|
||||
destination,
|
||||
runData,
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
expect(startNodes.size).toBe(1);
|
||||
expect(startNodes).toContainEqual(destination);
|
||||
|
@ -112,7 +130,13 @@ describe('findStartNodes', () => {
|
|||
};
|
||||
|
||||
// ACT
|
||||
const startNodes = findStartNodes({ graph, trigger, destination: node, runData });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger,
|
||||
destination: node,
|
||||
runData,
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(startNodes.size).toBe(1);
|
||||
|
@ -153,7 +177,13 @@ describe('findStartNodes', () => {
|
|||
|
||||
{
|
||||
// ACT
|
||||
const startNodes = findStartNodes({ graph, trigger, destination: node4 });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger,
|
||||
destination: node4,
|
||||
pinData: {},
|
||||
runData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(startNodes.size).toBe(1);
|
||||
|
@ -172,7 +202,13 @@ describe('findStartNodes', () => {
|
|||
};
|
||||
|
||||
// ACT
|
||||
const startNodes = findStartNodes({ graph, trigger, destination: node4, runData });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger,
|
||||
destination: node4,
|
||||
runData,
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(startNodes.size).toBe(1);
|
||||
|
@ -208,6 +244,7 @@ describe('findStartNodes', () => {
|
|||
runData: {
|
||||
[trigger.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 0 }])],
|
||||
},
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
|
@ -243,6 +280,7 @@ describe('findStartNodes', () => {
|
|||
runData: {
|
||||
[trigger.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 1 }])],
|
||||
},
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
|
@ -283,6 +321,7 @@ describe('findStartNodes', () => {
|
|||
]),
|
||||
],
|
||||
},
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
|
@ -321,6 +360,7 @@ describe('findStartNodes', () => {
|
|||
[node1.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 0 }])],
|
||||
[node2.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 0 }])],
|
||||
},
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
|
@ -357,6 +397,7 @@ describe('findStartNodes', () => {
|
|||
[trigger.name]: [toITaskData([{ data: { value: 1 } }])],
|
||||
[node1.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 1 }])],
|
||||
},
|
||||
pinData: {},
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
|
@ -389,7 +430,13 @@ describe('findStartNodes', () => {
|
|||
const pinData: IPinData = {};
|
||||
|
||||
// ACT
|
||||
const startNodes = findStartNodes({ graph, trigger, destination: node2, runData, pinData });
|
||||
const startNodes = findStartNodes({
|
||||
graph,
|
||||
trigger,
|
||||
destination: node2,
|
||||
runData,
|
||||
pinData,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(startNodes.size).toBe(1);
|
||||
|
|
|
@ -135,14 +135,14 @@ export function findStartNodes(options: {
|
|||
graph: DirectedGraph;
|
||||
trigger: INode;
|
||||
destination: INode;
|
||||
runData?: IRunData;
|
||||
pinData?: IPinData;
|
||||
pinData: IPinData;
|
||||
runData: IRunData;
|
||||
}): Set<INode> {
|
||||
const graph = options.graph;
|
||||
const trigger = options.trigger;
|
||||
const destination = options.destination;
|
||||
const runData = options.runData ?? {};
|
||||
const pinData = options.pinData ?? {};
|
||||
const runData = { ...options.runData };
|
||||
const pinData = options.pinData;
|
||||
|
||||
const startNodes = findStartNodesRecursive(
|
||||
graph,
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
import * as assert from 'assert/strict';
|
||||
import { setMaxListeners } from 'events';
|
||||
import { omit } from 'lodash';
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
ExecutionBaseError,
|
||||
|
@ -319,8 +320,9 @@ export class WorkflowExecute {
|
|||
runPartialWorkflow2(
|
||||
workflow: Workflow,
|
||||
runData: IRunData,
|
||||
pinData: IPinData = {},
|
||||
dirtyNodeNames: string[] = [],
|
||||
destinationNodeName?: string,
|
||||
pinData?: IPinData,
|
||||
): PCancelable<IRun> {
|
||||
// TODO: Refactor the call-site to make `destinationNodeName` a required
|
||||
// after removing the old partial execution flow.
|
||||
|
@ -349,7 +351,8 @@ export class WorkflowExecute {
|
|||
const filteredNodes = subgraph.getNodes();
|
||||
|
||||
// 3. Find the Start Nodes
|
||||
let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData });
|
||||
runData = omit(runData, dirtyNodeNames);
|
||||
let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData, pinData });
|
||||
|
||||
// 4. Detect Cycles
|
||||
// 5. Handle Cycles
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { mkdtempSync, readFileSync } from 'fs';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import { IncomingMessage } from 'http';
|
||||
import type { Agent } from 'https';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
|
@ -16,15 +16,19 @@ import type {
|
|||
import nock from 'nock';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import type { SecureContextOptions } from 'tls';
|
||||
import Container from 'typedi';
|
||||
|
||||
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
||||
import { InstanceSettings } from '@/InstanceSettings';
|
||||
import {
|
||||
binaryToString,
|
||||
copyInputItems,
|
||||
getBinaryDataBuffer,
|
||||
isFilePathBlocked,
|
||||
parseContentDisposition,
|
||||
parseContentType,
|
||||
parseIncomingMessage,
|
||||
parseRequestObject,
|
||||
proxyRequestToAxios,
|
||||
|
@ -148,6 +152,152 @@ describe('NodeExecuteFunctions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseContentType', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'text/plain',
|
||||
expected: {
|
||||
type: 'text/plain',
|
||||
parameters: {
|
||||
charset: 'utf-8',
|
||||
},
|
||||
},
|
||||
description: 'should parse basic content type',
|
||||
},
|
||||
{
|
||||
input: 'TEXT/PLAIN',
|
||||
expected: {
|
||||
type: 'text/plain',
|
||||
parameters: {
|
||||
charset: 'utf-8',
|
||||
},
|
||||
},
|
||||
description: 'should convert type to lowercase',
|
||||
},
|
||||
{
|
||||
input: 'text/html; charset=iso-8859-1',
|
||||
expected: {
|
||||
type: 'text/html',
|
||||
parameters: {
|
||||
charset: 'iso-8859-1',
|
||||
},
|
||||
},
|
||||
description: 'should parse content type with charset',
|
||||
},
|
||||
{
|
||||
input: 'application/json; charset=utf-8; boundary=---123',
|
||||
expected: {
|
||||
type: 'application/json',
|
||||
parameters: {
|
||||
charset: 'utf-8',
|
||||
boundary: '---123',
|
||||
},
|
||||
},
|
||||
description: 'should parse content type with multiple parameters',
|
||||
},
|
||||
{
|
||||
input: 'text/plain; charset="utf-8"; filename="test.txt"',
|
||||
expected: {
|
||||
type: 'text/plain',
|
||||
parameters: {
|
||||
charset: 'utf-8',
|
||||
filename: 'test.txt',
|
||||
},
|
||||
},
|
||||
description: 'should handle quoted parameter values',
|
||||
},
|
||||
{
|
||||
input: 'text/plain; filename=%22test%20file.txt%22',
|
||||
expected: {
|
||||
type: 'text/plain',
|
||||
parameters: {
|
||||
charset: 'utf-8',
|
||||
filename: 'test file.txt',
|
||||
},
|
||||
},
|
||||
description: 'should handle encoded parameter values',
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: null,
|
||||
description: 'should return null for undefined input',
|
||||
},
|
||||
{
|
||||
input: '',
|
||||
expected: null,
|
||||
description: 'should return null for empty string',
|
||||
},
|
||||
];
|
||||
|
||||
test.each(testCases)('$description', ({ input, expected }) => {
|
||||
expect(parseContentType(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseContentDisposition', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'attachment; filename="file.txt"',
|
||||
expected: { type: 'attachment', filename: 'file.txt' },
|
||||
description: 'should parse basic content disposition',
|
||||
},
|
||||
{
|
||||
input: 'attachment; filename=file.txt',
|
||||
expected: { type: 'attachment', filename: 'file.txt' },
|
||||
description: 'should parse filename without quotes',
|
||||
},
|
||||
{
|
||||
input: 'inline; filename="image.jpg"',
|
||||
expected: { type: 'inline', filename: 'image.jpg' },
|
||||
description: 'should parse inline disposition',
|
||||
},
|
||||
{
|
||||
input: 'attachment; filename="my file.pdf"',
|
||||
expected: { type: 'attachment', filename: 'my file.pdf' },
|
||||
description: 'should parse filename with spaces',
|
||||
},
|
||||
{
|
||||
input: "attachment; filename*=UTF-8''my%20file.txt",
|
||||
expected: { type: 'attachment', filename: 'my file.txt' },
|
||||
description: 'should parse filename* parameter (RFC 5987)',
|
||||
},
|
||||
{
|
||||
input: 'filename="test.txt"',
|
||||
expected: { type: 'attachment', filename: 'test.txt' },
|
||||
description: 'should handle invalid syntax but with filename',
|
||||
},
|
||||
{
|
||||
input: 'filename=test.txt',
|
||||
expected: { type: 'attachment', filename: 'test.txt' },
|
||||
description: 'should handle invalid syntax with only filename parameter',
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: null,
|
||||
description: 'should return null for undefined input',
|
||||
},
|
||||
{
|
||||
input: '',
|
||||
expected: null,
|
||||
description: 'should return null for empty string',
|
||||
},
|
||||
{
|
||||
input: 'attachment; filename="%F0%9F%98%80.txt"',
|
||||
expected: { type: 'attachment', filename: '😀.txt' },
|
||||
description: 'should handle encoded filenames',
|
||||
},
|
||||
{
|
||||
input: 'attachment; size=123; filename="test.txt"; creation-date="Thu, 1 Jan 2020"',
|
||||
expected: { type: 'attachment', filename: 'test.txt' },
|
||||
description: 'should handle multiple parameters',
|
||||
},
|
||||
];
|
||||
|
||||
test.each(testCases)('$description', ({ input, expected }) => {
|
||||
expect(parseContentDisposition(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIncomingMessage', () => {
|
||||
it('parses valid content-type header', () => {
|
||||
const message = mock<IncomingMessage>({
|
||||
|
@ -168,6 +318,20 @@ describe('NodeExecuteFunctions', () => {
|
|||
parseIncomingMessage(message);
|
||||
|
||||
expect(message.contentType).toEqual('application/json');
|
||||
expect(message.encoding).toEqual('utf-8');
|
||||
});
|
||||
|
||||
it('parses valid content-type header with encoding wrapped in quotes', () => {
|
||||
const message = mock<IncomingMessage>({
|
||||
headers: {
|
||||
'content-type': 'application/json; charset="utf-8"',
|
||||
'content-disposition': undefined,
|
||||
},
|
||||
});
|
||||
parseIncomingMessage(message);
|
||||
|
||||
expect(message.contentType).toEqual('application/json');
|
||||
expect(message.encoding).toEqual('utf-8');
|
||||
});
|
||||
|
||||
it('parses valid content-disposition header with filename*', () => {
|
||||
|
@ -549,6 +713,101 @@ describe('NodeExecuteFunctions', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('binaryToString', () => {
|
||||
const ENCODING_SAMPLES = {
|
||||
utf8: {
|
||||
text: 'Hello, 世界! τεστ мир ⚡️ é à ü ñ',
|
||||
buffer: Buffer.from([
|
||||
0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, 0x21, 0x20,
|
||||
0xcf, 0x84, 0xce, 0xb5, 0xcf, 0x83, 0xcf, 0x84, 0x20, 0xd0, 0xbc, 0xd0, 0xb8, 0xd1, 0x80,
|
||||
0x20, 0xe2, 0x9a, 0xa1, 0xef, 0xb8, 0x8f, 0x20, 0xc3, 0xa9, 0x20, 0xc3, 0xa0, 0x20, 0xc3,
|
||||
0xbc, 0x20, 0xc3, 0xb1,
|
||||
]),
|
||||
},
|
||||
|
||||
'iso-8859-15': {
|
||||
text: 'Café € personnalité',
|
||||
buffer: Buffer.from([
|
||||
0x43, 0x61, 0x66, 0xe9, 0x20, 0xa4, 0x20, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x6e, 0x61,
|
||||
0x6c, 0x69, 0x74, 0xe9,
|
||||
]),
|
||||
},
|
||||
|
||||
latin1: {
|
||||
text: 'señor année déjà',
|
||||
buffer: Buffer.from([
|
||||
0x73, 0x65, 0xf1, 0x6f, 0x72, 0x20, 0x61, 0x6e, 0x6e, 0xe9, 0x65, 0x20, 0x64, 0xe9, 0x6a,
|
||||
0xe0,
|
||||
]),
|
||||
},
|
||||
|
||||
ascii: {
|
||||
text: 'Hello, World! 123',
|
||||
buffer: Buffer.from([
|
||||
0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x20, 0x31,
|
||||
0x32, 0x33,
|
||||
]),
|
||||
},
|
||||
|
||||
'windows-1252': {
|
||||
text: '€ Smart "quotes" • bullet',
|
||||
buffer: Buffer.from([
|
||||
0x80, 0x20, 0x53, 0x6d, 0x61, 0x72, 0x74, 0x20, 0x22, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x73,
|
||||
0x22, 0x20, 0x95, 0x20, 0x62, 0x75, 0x6c, 0x6c, 0x65, 0x74,
|
||||
]),
|
||||
},
|
||||
|
||||
'shift-jis': {
|
||||
text: 'こんにちは世界',
|
||||
buffer: Buffer.from([
|
||||
0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd, 0x90, 0xa2, 0x8a, 0x45,
|
||||
]),
|
||||
},
|
||||
|
||||
big5: {
|
||||
text: '哈囉世界',
|
||||
buffer: Buffer.from([0xab, 0xa2, 0xc5, 0x6f, 0xa5, 0x40, 0xac, 0xc9]),
|
||||
},
|
||||
|
||||
'koi8-r': {
|
||||
text: 'Привет мир',
|
||||
buffer: Buffer.from([0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x20, 0xcd, 0xc9, 0xd2]),
|
||||
},
|
||||
};
|
||||
|
||||
describe('should handle Buffer', () => {
|
||||
for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) {
|
||||
test(`with ${encoding}`, async () => {
|
||||
const data = await binaryToString(buffer, encoding);
|
||||
expect(data).toBe(text);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('should handle streams', () => {
|
||||
for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) {
|
||||
test(`with ${encoding}`, async () => {
|
||||
const stream = Readable.from(buffer);
|
||||
const data = await binaryToString(stream, encoding);
|
||||
expect(data).toBe(text);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('should handle IncomingMessage', () => {
|
||||
for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) {
|
||||
test(`with ${encoding}`, async () => {
|
||||
const response = Readable.from(buffer) as IncomingMessage;
|
||||
response.headers = { 'content-type': `application/json;charset=${encoding}` };
|
||||
// @ts-expect-error need this hack to fake `instanceof IncomingMessage` checks
|
||||
response.__proto__ = IncomingMessage.prototype;
|
||||
const data = await binaryToString(response);
|
||||
expect(data).toBe(text);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFilePathBlocked', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { IRun, WorkflowTestData } from 'n8n-workflow';
|
||||
import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
createDeferredPromise,
|
||||
|
@ -6,17 +6,20 @@ import {
|
|||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DirectedGraph } from '@/PartialExecutionUtils';
|
||||
import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers';
|
||||
import { WorkflowExecute } from '@/WorkflowExecute';
|
||||
|
||||
import * as Helpers from './helpers';
|
||||
import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from './helpers/constants';
|
||||
|
||||
const nodeTypes = Helpers.NodeTypes();
|
||||
|
||||
describe('WorkflowExecute', () => {
|
||||
describe('v0 execution order', () => {
|
||||
const tests: WorkflowTestData[] = legacyWorkflowExecuteTests;
|
||||
|
||||
const executionMode = 'manual';
|
||||
const nodeTypes = Helpers.NodeTypes();
|
||||
|
||||
for (const testData of tests) {
|
||||
test(testData.description, async () => {
|
||||
|
@ -217,4 +220,49 @@ describe('WorkflowExecute', () => {
|
|||
expect(nodeExecutionOutput[0][0].json.data).toEqual(123);
|
||||
expect(nodeExecutionOutput.getHints()[0].message).toEqual('TEXT HINT');
|
||||
});
|
||||
|
||||
describe('runPartialWorkflow2', () => {
|
||||
// Dirty ►
|
||||
// ┌───────┐1 ┌─────┐1 ┌─────┐
|
||||
// │trigger├──────►node1├──────►node2│
|
||||
// └───────┘ └─────┘ └─────┘
|
||||
test("deletes dirty nodes' run data", async () => {
|
||||
// ARRANGE
|
||||
const waitPromise = createDeferredPromise<IRun>();
|
||||
const nodeExecutionOrder: string[] = [];
|
||||
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder);
|
||||
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
|
||||
|
||||
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
|
||||
const node1 = createNodeData({ name: 'node1' });
|
||||
const node2 = createNodeData({ name: 'node2' });
|
||||
const workflow = new DirectedGraph()
|
||||
.addNodes(trigger, node1, node2)
|
||||
.addConnections({ from: trigger, to: node1 }, { from: node1, to: node2 })
|
||||
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||
const pinData: IPinData = {};
|
||||
const runData: IRunData = {
|
||||
[trigger.name]: [toITaskData([{ data: { name: trigger.name } }])],
|
||||
[node1.name]: [toITaskData([{ data: { name: node1.name } }])],
|
||||
[node2.name]: [toITaskData([{ data: { name: node2.name } }])],
|
||||
};
|
||||
const dirtyNodeNames = [node1.name];
|
||||
|
||||
jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn());
|
||||
|
||||
// ACT
|
||||
await workflowExecute.runPartialWorkflow2(
|
||||
workflow,
|
||||
runData,
|
||||
pinData,
|
||||
dirtyNodeNames,
|
||||
'node2',
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
const fullRunData = workflowExecute.getFullRunData(new Date());
|
||||
expect(fullRunData.data.resultData.runData).toHaveProperty(trigger.name);
|
||||
expect(fullRunData.data.resultData.runData).not.toHaveProperty(node1.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import type {
|
|||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { If } from '../../../nodes-base/dist/nodes/If/If.node';
|
||||
import { ManualTrigger } from '../../../nodes-base/dist/nodes/ManualTrigger/ManualTrigger.node';
|
||||
import { Merge } from '../../../nodes-base/dist/nodes/Merge/Merge.node';
|
||||
import { NoOp } from '../../../nodes-base/dist/nodes/NoOp/NoOp.node';
|
||||
import { Set } from '../../../nodes-base/dist/nodes/Set/Set.node';
|
||||
|
@ -33,6 +34,10 @@ export const predefinedNodesTypes: INodeTypeData = {
|
|||
type: new Start(),
|
||||
sourcePath: '',
|
||||
},
|
||||
'n8n-nodes-base.manualTrigger': {
|
||||
type: new ManualTrigger(),
|
||||
sourcePath: '',
|
||||
},
|
||||
'n8n-nodes-base.versionTest': {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-design-system",
|
||||
"version": "1.59.0",
|
||||
"version": "1.60.0",
|
||||
"main": "src/main.ts",
|
||||
"import": "src/main.ts",
|
||||
"scripts": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ElMenu, ElSubMenu, ElMenuItem, type MenuItemRegistered } from 'element-plus';
|
||||
import { ref, defineProps, defineEmits, defineOptions } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
import ConditionalRouterLink from '../ConditionalRouterLink';
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { directionsCursorMaps, type Direction, type ResizeData } from 'n8n-design-system/types';
|
||||
|
||||
function closestNumber(value: number, divisor: number): number {
|
||||
const q = value / divisor;
|
||||
const n1 = divisor * q;
|
||||
|
@ -21,19 +23,6 @@ function getSize(min: number, virtual: number, gridSize: number): number {
|
|||
return min;
|
||||
}
|
||||
|
||||
const directionsCursorMaps = {
|
||||
right: 'ew-resize',
|
||||
top: 'ns-resize',
|
||||
bottom: 'ns-resize',
|
||||
left: 'ew-resize',
|
||||
topLeft: 'nw-resize',
|
||||
topRight: 'ne-resize',
|
||||
bottomLeft: 'sw-resize',
|
||||
bottomRight: 'se-resize',
|
||||
} as const;
|
||||
|
||||
type Direction = keyof typeof directionsCursorMaps;
|
||||
|
||||
interface ResizeProps {
|
||||
isResizingEnabled?: boolean;
|
||||
height?: number;
|
||||
|
@ -56,16 +45,6 @@ const props = withDefaults(defineProps<ResizeProps>(), {
|
|||
supportedDirections: () => [],
|
||||
});
|
||||
|
||||
export interface ResizeData {
|
||||
height: number;
|
||||
width: number;
|
||||
dX: number;
|
||||
dY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
resizestart: [];
|
||||
resize: [value: ResizeData];
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
|
||||
import N8nResizeWrapper, { type ResizeData } from '../N8nResizeWrapper/ResizeWrapper.vue';
|
||||
import { type ResizeData } from 'n8n-design-system/types';
|
||||
|
||||
import N8nResizeWrapper from '../N8nResizeWrapper/ResizeWrapper.vue';
|
||||
import { defaultStickyProps } from '../N8nSticky/constants';
|
||||
import N8nSticky from '../N8nSticky/Sticky.vue';
|
||||
import type { StickyProps } from '../N8nSticky/types';
|
||||
|
|
|
@ -9,3 +9,4 @@ export * from './select';
|
|||
export * from './user';
|
||||
export * from './keyboardshortcut';
|
||||
export * from './node-creator-node';
|
||||
export * from './resize';
|
||||
|
|
22
packages/design-system/src/types/resize.ts
Normal file
22
packages/design-system/src/types/resize.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export const directionsCursorMaps = {
|
||||
right: 'ew-resize',
|
||||
top: 'ns-resize',
|
||||
bottom: 'ns-resize',
|
||||
left: 'ew-resize',
|
||||
topLeft: 'nw-resize',
|
||||
topRight: 'ne-resize',
|
||||
bottomLeft: 'sw-resize',
|
||||
bottomRight: 'se-resize',
|
||||
} as const;
|
||||
|
||||
export type Direction = keyof typeof directionsCursorMaps;
|
||||
|
||||
export interface ResizeData {
|
||||
height: number;
|
||||
width: number;
|
||||
dX: number;
|
||||
dY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
direction: Direction;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "1.69.0",
|
||||
"version": "1.70.0",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -79,6 +79,7 @@
|
|||
"vue-json-pretty": "2.2.4",
|
||||
"vue-markdown-render": "catalog:frontend",
|
||||
"vue-router": "catalog:frontend",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8",
|
||||
"vue3-touch-events": "^4.1.3",
|
||||
"xss": "catalog:"
|
||||
},
|
||||
|
|
|
@ -200,6 +200,7 @@ export interface IStartRunData {
|
|||
startNodes?: StartNodeData[];
|
||||
destinationNode?: string;
|
||||
runData?: IRunData;
|
||||
dirtyNodeNames?: string[];
|
||||
}
|
||||
|
||||
export interface ITableData {
|
||||
|
@ -1474,6 +1475,7 @@ export interface ExternalSecretsProvider {
|
|||
export type CloudUpdateLinkSourceType =
|
||||
| 'advanced-permissions'
|
||||
| 'canvas-nav'
|
||||
| 'concurrency'
|
||||
| 'custom-data-filter'
|
||||
| 'workflow_sharing'
|
||||
| 'credential_sharing'
|
||||
|
@ -1496,6 +1498,7 @@ export type CloudUpdateLinkSourceType =
|
|||
export type UTMCampaign =
|
||||
| 'upgrade-custom-data-filter'
|
||||
| 'upgrade-canvas-nav'
|
||||
| 'upgrade-concurrency'
|
||||
| 'upgrade-workflow-sharing'
|
||||
| 'upgrade-credentials-sharing'
|
||||
| 'upgrade-api'
|
||||
|
|
|
@ -14,6 +14,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
|
|||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import type { EventBus } from 'n8n-design-system';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import type { ViewportTransform } from '@vue-flow/core';
|
||||
|
||||
export function createCanvasNodeData({
|
||||
id = 'node',
|
||||
|
@ -91,16 +92,22 @@ export function createCanvasNodeProps({
|
|||
}
|
||||
|
||||
export function createCanvasProvide({
|
||||
initialized = true,
|
||||
isExecuting = false,
|
||||
connectingHandle = undefined,
|
||||
viewport = { x: 0, y: 0, zoom: 1 },
|
||||
}: {
|
||||
initialized?: boolean;
|
||||
isExecuting?: boolean;
|
||||
connectingHandle?: ConnectStartEvent;
|
||||
viewport?: ViewportTransform;
|
||||
} = {}) {
|
||||
return {
|
||||
[String(CanvasKey)]: {
|
||||
initialized: ref(initialized),
|
||||
isExecuting: ref(isExecuting),
|
||||
connectingHandle: ref(connectingHandle),
|
||||
viewport: ref(viewport),
|
||||
} satisfies CanvasInjectionData,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -126,4 +126,5 @@ export const defaultSettings: FrontendSettings = {
|
|||
enabled: false,
|
||||
},
|
||||
betaFeatures: [],
|
||||
virtualSchemaView: false,
|
||||
};
|
||||
|
|
|
@ -80,6 +80,10 @@ export function createComponentRenderer(
|
|||
global: {
|
||||
...defaultOptions.global,
|
||||
...options.global,
|
||||
provide: {
|
||||
...defaultOptions.global?.provide,
|
||||
...options.global?.provide,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -136,3 +136,5 @@ export const mockedStore = <TStoreDef extends () => unknown>(
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return useStore() as any;
|
||||
};
|
||||
|
||||
export type MockedStore<T extends () => unknown> = ReturnType<typeof mockedStore<T>>;
|
||||
|
|
73
packages/editor-ui/src/api/testDefinition.ee.ts
Normal file
73
packages/editor-ui/src/api/testDefinition.ee.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import type { IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
export interface TestDefinitionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
workflowId: string;
|
||||
evaluationWorkflowId?: string | null;
|
||||
annotationTagId?: string | null;
|
||||
description?: string | null;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
interface CreateTestDefinitionParams {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
evaluationWorkflowId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateTestDefinitionParams {
|
||||
name?: string;
|
||||
evaluationWorkflowId?: string | null;
|
||||
annotationTagId?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
export interface UpdateTestResponse {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description: string | null;
|
||||
annotationTag: string | null;
|
||||
evaluationWorkflowId: string | null;
|
||||
annotationTagId: string | null;
|
||||
}
|
||||
|
||||
const endpoint = '/evaluation/test-definitions';
|
||||
|
||||
export async function getTestDefinitions(context: IRestApiContext) {
|
||||
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
|
||||
context,
|
||||
'GET',
|
||||
endpoint,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTestDefinition(context: IRestApiContext, id: string) {
|
||||
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
|
||||
}
|
||||
|
||||
export async function createTestDefinition(
|
||||
context: IRestApiContext,
|
||||
params: CreateTestDefinitionParams,
|
||||
) {
|
||||
return await makeRestApiRequest<TestDefinitionRecord>(context, 'POST', endpoint, params);
|
||||
}
|
||||
|
||||
export async function updateTestDefinition(
|
||||
context: IRestApiContext,
|
||||
id: string,
|
||||
params: UpdateTestDefinitionParams,
|
||||
) {
|
||||
return await makeRestApiRequest<UpdateTestResponse>(
|
||||
context,
|
||||
'PATCH',
|
||||
`${endpoint}/${id}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
|
||||
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
|
||||
}
|
|
@ -71,7 +71,7 @@ function onClose() {
|
|||
|
||||
<template>
|
||||
<SlideTransition>
|
||||
<n8n-resize-wrapper
|
||||
<N8nResizeWrapper
|
||||
v-show="assistantStore.isAssistantOpen"
|
||||
:supported-directions="['left']"
|
||||
:width="assistantStore.chatWidth"
|
||||
|
@ -97,7 +97,7 @@ function onClose() {
|
|||
@code-undo="undoCodeDiff"
|
||||
/>
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
</N8nResizeWrapper>
|
||||
</SlideTransition>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue';
|
|||
import ParameterInputHint from '@/components/ParameterInputHint.vue';
|
||||
import ParameterIssues from '@/components/ParameterIssues.vue';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { isExpression, stringifyExpressionResult } from '@/utils/expressions';
|
||||
import type { AssignmentValue, INodeProperties, Result } from 'n8n-workflow';
|
||||
import { computed, ref } from 'vue';
|
||||
|
@ -101,7 +102,12 @@ const hint = computed(() => {
|
|||
result = { ok: false, error };
|
||||
}
|
||||
|
||||
return stringifyExpressionResult(result);
|
||||
const hasRunData =
|
||||
!!useWorkflowsStore().workflowExecutionData?.data?.resultData?.runData[
|
||||
ndvStore.activeNode?.name ?? ''
|
||||
];
|
||||
|
||||
return stringifyExpressionResult(result, hasRunData);
|
||||
});
|
||||
|
||||
const highlightHint = computed(() => Boolean(hint.value && ndvStore.getHoveringItem));
|
||||
|
|
|
@ -45,6 +45,7 @@ describe('ButtonParameter', () => {
|
|||
vi.mocked(useNDVStore).mockReturnValue({
|
||||
ndvInputData: [{}],
|
||||
activeNode: { name: 'TestNode', parameters: {} },
|
||||
isDraggableDragging: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useWorkflowsStore).mockReturnValue({
|
|
@ -6,10 +6,18 @@ import { N8nButton, N8nInput, N8nTooltip } from 'n8n-design-system/components';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { getParentNodes, generateCodeForAiTransform } from './utils';
|
||||
import {
|
||||
getParentNodes,
|
||||
generateCodeForAiTransform,
|
||||
type TextareaRowData,
|
||||
getUpdatedTextareaValue,
|
||||
getTextareaCursorPosition,
|
||||
} from './utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
import { propertyNameFromExpression } from '../../utils/mappingUtils';
|
||||
|
||||
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -29,6 +37,7 @@ const i18n = useI18n();
|
|||
const isLoading = ref(false);
|
||||
const prompt = ref(props.value);
|
||||
const parentNodes = ref<INodeUi[]>([]);
|
||||
const textareaRowsData = ref<TextareaRowData | null>(null);
|
||||
|
||||
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
|
||||
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
|
||||
|
@ -96,6 +105,7 @@ async function onSubmit() {
|
|||
const updateInformation = await generateCodeForAiTransform(
|
||||
prompt.value,
|
||||
getPath(target as string),
|
||||
5,
|
||||
);
|
||||
if (!updateInformation) return;
|
||||
|
||||
|
@ -159,6 +169,37 @@ function useDarkBackdrop(): string {
|
|||
onMounted(() => {
|
||||
parentNodes.value = getParentNodes();
|
||||
});
|
||||
|
||||
function cleanTextareaRowsData() {
|
||||
textareaRowsData.value = null;
|
||||
}
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
value = propertyNameFromExpression(value);
|
||||
|
||||
prompt.value = getUpdatedTextareaValue(event, textareaRowsData.value, value);
|
||||
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: prompt.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: boolean) {
|
||||
if (!activeDrop) return;
|
||||
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
|
||||
const position = getTextareaCursorPosition(
|
||||
textarea,
|
||||
textareaRowsData.value,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(position, position);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -186,16 +227,25 @@ onMounted(() => {
|
|||
v-text="'Instructions changed'"
|
||||
/>
|
||||
</div>
|
||||
<N8nInput
|
||||
v-model="prompt"
|
||||
:class="$style.input"
|
||||
style="border: 1px solid var(--color-foreground-base)"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="inputFieldMaxLength"
|
||||
:placeholder="parameter.placeholder"
|
||||
@input="onPromptInput"
|
||||
/>
|
||||
<DraggableTarget type="mapping" :disabled="isLoading" @drop="onDrop">
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<N8nInput
|
||||
v-model="prompt"
|
||||
:class="[
|
||||
$style.input,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
style="border: 1.5px solid var(--color-foreground-base)"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="inputFieldMaxLength"
|
||||
:placeholder="parameter.placeholder"
|
||||
@input="onPromptInput"
|
||||
@mousemove="updateCursorPositionOnMouseMove($event, activeDrop)"
|
||||
@mouseleave="cleanTextareaRowsData"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<N8nTooltip :disabled="isSubmitEnabled">
|
||||
|
@ -227,7 +277,7 @@ onMounted(() => {
|
|||
|
||||
<style module lang="scss">
|
||||
.input * {
|
||||
border: 0 !important;
|
||||
border: 1.5px transparent !important;
|
||||
}
|
||||
.input textarea {
|
||||
font-size: var(--font-size-2xs);
|
||||
|
@ -277,4 +327,11 @@ onMounted(() => {
|
|||
color: var(--color-warning);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.droppable {
|
||||
border: 1.5px dashed var(--color-ndv-droppable-parameter) !important;
|
||||
}
|
||||
.activeDrop {
|
||||
border: 1.5px solid var(--color-success) !important;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { generateCodeForAiTransform } from './utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
|
||||
vi.mock('./utils', async () => {
|
||||
const actual = await vi.importActual('./utils');
|
||||
return {
|
||||
...actual,
|
||||
getSchemas: vi.fn(() => ({
|
||||
parentNodesSchemas: { test: 'parentSchema' },
|
||||
inputSchema: { test: 'inputSchema' },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/root.store', () => ({
|
||||
useRootStore: () => ({
|
||||
pushRef: 'mockRootPushRef',
|
||||
restApiContext: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/ndv.store', () => ({
|
||||
useNDVStore: () => ({
|
||||
pushRef: 'mockNdvPushRef',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock('prettier', () => ({
|
||||
format: vi.fn(async (code) => await Promise.resolve(`formatted-${code}`)),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/ai', () => ({
|
||||
generateCodeForPrompt: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('generateCodeForAiTransform - Retry Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
it('should retry and succeed on the second attempt', async () => {
|
||||
const mockGeneratedCode = 'const example = "retry success";';
|
||||
|
||||
vi.mocked(generateCodeForPrompt)
|
||||
.mockRejectedValueOnce(new Error('First attempt failed'))
|
||||
.mockResolvedValueOnce({ code: mockGeneratedCode });
|
||||
|
||||
const result = await generateCodeForAiTransform('test prompt', 'test/path', 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test/path',
|
||||
value: 'formatted-const example = "retry success";',
|
||||
});
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should exhaust retries and throw an error', async () => {
|
||||
vi.mocked(generateCodeForPrompt).mockRejectedValue(new Error('All attempts failed'));
|
||||
|
||||
await expect(generateCodeForAiTransform('test prompt', 'test/path', 3)).rejects.toThrow(
|
||||
'All attempts failed',
|
||||
);
|
||||
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should succeed on the first attempt without retries', async () => {
|
||||
const mockGeneratedCode = 'const example = "no retries needed";';
|
||||
vi.mocked(generateCodeForPrompt).mockResolvedValue({ code: mockGeneratedCode });
|
||||
|
||||
const result = await generateCodeForAiTransform('test prompt', 'test/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test/path',
|
||||
value: 'formatted-const example = "no retries needed";',
|
||||
});
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -4,14 +4,19 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { generateCodeForPrompt } from '../../api/ai';
|
||||
import { useRootStore } from '../../stores/root.store';
|
||||
import { type AskAiRequest } from '../../types/assistant.types';
|
||||
import { useSettingsStore } from '../../stores/settings.store';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { type AskAiRequest } from '@/types/assistant.types';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
|
||||
export type TextareaRowData = {
|
||||
rows: string[];
|
||||
linesToRowsMap: number[][];
|
||||
};
|
||||
|
||||
export function getParentNodes() {
|
||||
const activeNode = useNDVStore().activeNode;
|
||||
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
|
||||
|
@ -38,7 +43,7 @@ export function getSchemas() {
|
|||
|
||||
return {
|
||||
nodeName: node?.name || '',
|
||||
schema: getSchemaForExecutionData(executionDataToJson(inputData), true),
|
||||
schema: getSchemaForExecutionData(executionDataToJson(inputData), false),
|
||||
};
|
||||
})
|
||||
.filter((node) => node.schema?.value.length > 0);
|
||||
|
@ -52,7 +57,7 @@ export function getSchemas() {
|
|||
};
|
||||
}
|
||||
|
||||
export async function generateCodeForAiTransform(prompt: string, path: string) {
|
||||
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
|
||||
const schemas = getSchemas();
|
||||
|
||||
const payload: AskAiRequest.RequestPayload = {
|
||||
|
@ -69,7 +74,20 @@ export async function generateCodeForAiTransform(prompt: string, path: string) {
|
|||
let value;
|
||||
if (useSettingsStore().isAskAiEnabled) {
|
||||
const { restApiContext } = useRootStore();
|
||||
const { code } = await generateCodeForPrompt(restApiContext, payload);
|
||||
|
||||
let code = '';
|
||||
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const { code: generatedCode } = await generateCodeForPrompt(restApiContext, payload);
|
||||
code = generatedCode;
|
||||
break;
|
||||
} catch (e) {
|
||||
retries--;
|
||||
if (!retries) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
value = code;
|
||||
} else {
|
||||
throw new ApplicationError('AI code generation is not enabled');
|
||||
|
@ -89,3 +107,164 @@ export async function generateCodeForAiTransform(prompt: string, path: string) {
|
|||
|
||||
return updateInformation;
|
||||
}
|
||||
|
||||
//------ drag and drop ------
|
||||
|
||||
function splitText(textarea: HTMLTextAreaElement, textareaRowsData: TextareaRowData | null) {
|
||||
if (textareaRowsData) return textareaRowsData;
|
||||
const rows: string[] = [];
|
||||
const linesToRowsMap: number[][] = [];
|
||||
const style = window.getComputedStyle(textarea);
|
||||
|
||||
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const border = parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
const textareaWidth = textarea.clientWidth - padding - border;
|
||||
|
||||
const context = createTextContext(style);
|
||||
|
||||
const lines = textarea.value.split('\n');
|
||||
|
||||
lines.forEach((_) => {
|
||||
linesToRowsMap.push([]);
|
||||
});
|
||||
lines.forEach((line, index) => {
|
||||
if (line === '') {
|
||||
rows.push(line);
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
return;
|
||||
}
|
||||
let currentLine = '';
|
||||
const words = line.split(/(\s+)/);
|
||||
|
||||
words.forEach((word) => {
|
||||
const testLine = currentLine + word;
|
||||
const testWidth = context.measureText(testLine).width;
|
||||
|
||||
if (testWidth <= textareaWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
rows.push(currentLine.trimEnd());
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
currentLine = word;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentLine) {
|
||||
rows.push(currentLine.trimEnd());
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
return { rows, linesToRowsMap };
|
||||
}
|
||||
|
||||
function createTextContext(style: CSSStyleDeclaration): CanvasRenderingContext2D {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||
return context;
|
||||
}
|
||||
|
||||
const getRowIndex = (textareaY: number, lineHeight: string) => {
|
||||
const rowHeight = parseInt(lineHeight, 10);
|
||||
const snapPosition = textareaY - rowHeight / 2 - 1;
|
||||
return Math.floor(snapPosition / rowHeight);
|
||||
};
|
||||
|
||||
const getColumnIndex = (rowText: string, textareaX: number, font: string) => {
|
||||
const span = document.createElement('span');
|
||||
span.style.font = font;
|
||||
span.style.visibility = 'hidden';
|
||||
span.style.position = 'absolute';
|
||||
span.style.whiteSpace = 'pre';
|
||||
document.body.appendChild(span);
|
||||
|
||||
let left = 0;
|
||||
let right = rowText.length;
|
||||
let col = 0;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
span.textContent = rowText.substring(0, mid);
|
||||
const width = span.getBoundingClientRect().width;
|
||||
|
||||
if (width <= textareaX) {
|
||||
col = mid;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeChild(span);
|
||||
|
||||
return rowText.length === col ? col : col - 1;
|
||||
};
|
||||
|
||||
export function getUpdatedTextareaValue(
|
||||
event: MouseEvent,
|
||||
textareaRowsData: TextareaRowData | null,
|
||||
value: string,
|
||||
) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const textareaX = event.clientX - rect.left;
|
||||
const textareaY = event.clientY - rect.top;
|
||||
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||
|
||||
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||
|
||||
const rowsData = splitText(textarea, textareaRowsData);
|
||||
|
||||
let newText = value;
|
||||
|
||||
if (rowsData.rows[rowIndex] === undefined) {
|
||||
newText = `${textarea.value} ${value}`;
|
||||
}
|
||||
const { rows, linesToRowsMap } = rowsData;
|
||||
const rowText = rows[rowIndex];
|
||||
|
||||
if (rowText === '') {
|
||||
rows[rowIndex] = value;
|
||||
} else {
|
||||
const col = getColumnIndex(rowText, textareaX, font);
|
||||
rows[rowIndex] = [rows[rowIndex].slice(0, col).trim(), value, rows[rowIndex].slice(col).trim()]
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
newText = linesToRowsMap
|
||||
.map((lineMap) => {
|
||||
return lineMap.map((index) => rows[index]).join(' ');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return newText;
|
||||
}
|
||||
|
||||
export function getTextareaCursorPosition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
textareaRowsData: TextareaRowData | null,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const textareaX = clientX - rect.left;
|
||||
const textareaY = clientY - rect.top;
|
||||
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||
|
||||
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||
const { rows } = splitText(textarea, textareaRowsData);
|
||||
|
||||
if (rowIndex < 0 || rowIndex >= rows.length) {
|
||||
return textarea.value.length;
|
||||
}
|
||||
|
||||
const rowText = rows[rowIndex];
|
||||
|
||||
const col = getColumnIndex(rowText, textareaX, font);
|
||||
|
||||
const position = rows.slice(0, rowIndex).reduce((acc, curr) => acc + curr.length + 1, 0) + col;
|
||||
|
||||
return position;
|
||||
}
|
||||
|
|
|
@ -240,7 +240,7 @@ watchEffect(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-resize-wrapper
|
||||
<N8nResizeWrapper
|
||||
v-if="chatTriggerNode"
|
||||
:is-resizing-enabled="isChatOpen || isLogsOpen"
|
||||
:supported-directions="['top']"
|
||||
|
@ -282,7 +282,7 @@ watchEffect(() => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
</N8nResizeWrapper>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import type { Ref } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue';
|
||||
import type { ResizeData } from 'n8n-design-system/components/N8nResizeWrapper/ResizeWrapper.vue';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import type { IChatResizeStyles } from '../types/chat';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { type ResizeData } from 'n8n-design-system';
|
||||
|
||||
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
|
||||
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
|
||||
|
|
|
@ -23,6 +23,10 @@ import { dropInExpressionEditor } from '@/plugins/codemirror/dragAndDrop';
|
|||
|
||||
import { APP_MODALS_ELEMENT_ID } from '@/constants';
|
||||
import { N8nInput, N8nText } from 'n8n-design-system';
|
||||
import { N8nResizeWrapper, type ResizeData } from 'n8n-design-system';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
|
||||
const DEFAULT_LEFT_SIDEBAR_WIDTH = 360;
|
||||
|
||||
type Props = {
|
||||
parameter: INodeProperties;
|
||||
|
@ -56,6 +60,7 @@ const { debounce } = useDebounce();
|
|||
const segments = ref<Segment[]>([]);
|
||||
const search = ref('');
|
||||
const appliedSearch = ref('');
|
||||
const sidebarWidth = ref(DEFAULT_LEFT_SIDEBAR_WIDTH);
|
||||
const expressionInputRef = ref<InstanceType<typeof ExpressionEditorModalInput>>();
|
||||
const expressionResultRef = ref<InstanceType<typeof ExpressionOutput>>();
|
||||
const theme = outputTheme();
|
||||
|
@ -122,6 +127,12 @@ async function onDrop(expression: string, event: MouseEvent) {
|
|||
|
||||
await dropInExpressionEditor(toRaw(inputEditor.value), event, expression);
|
||||
}
|
||||
|
||||
function onResize(event: ResizeData) {
|
||||
sidebarWidth.value = event.width;
|
||||
}
|
||||
|
||||
const onResizeThrottle = useThrottleFn(onResize, 10);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -136,28 +147,37 @@ async function onDrop(expression: string, event: MouseEvent) {
|
|||
<Close height="18" width="18" />
|
||||
</button>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.sidebar">
|
||||
<N8nInput
|
||||
v-model="search"
|
||||
size="small"
|
||||
:class="$style.search"
|
||||
:placeholder="i18n.baseText('ndv.search.placeholder.input.schema')"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon :class="$style.ioSearchIcon" icon="search" />
|
||||
</template>
|
||||
</N8nInput>
|
||||
<N8nResizeWrapper
|
||||
:width="sidebarWidth"
|
||||
:min-width="200"
|
||||
:style="{ width: `${sidebarWidth}px` }"
|
||||
:grid-size="8"
|
||||
:supported-directions="['left', 'right']"
|
||||
@resize="onResizeThrottle"
|
||||
>
|
||||
<div :class="$style.sidebar">
|
||||
<N8nInput
|
||||
v-model="search"
|
||||
size="small"
|
||||
:class="$style.search"
|
||||
:placeholder="i18n.baseText('ndv.search.placeholder.input.schema')"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon :class="$style.ioSearchIcon" icon="search" />
|
||||
</template>
|
||||
</N8nInput>
|
||||
|
||||
<RunDataSchema
|
||||
:class="$style.schema"
|
||||
:search="appliedSearch"
|
||||
:nodes="parentNodes"
|
||||
:mapping-enabled="!isReadOnly"
|
||||
:connection-type="NodeConnectionType.Main"
|
||||
pane-type="input"
|
||||
context="modal"
|
||||
/>
|
||||
</div>
|
||||
<RunDataSchema
|
||||
:class="$style.schema"
|
||||
:search="appliedSearch"
|
||||
:nodes="parentNodes"
|
||||
:mapping-enabled="!isReadOnly"
|
||||
:connection-type="NodeConnectionType.Main"
|
||||
pane-type="input"
|
||||
context="modal"
|
||||
/>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
|
||||
<div :class="$style.io">
|
||||
<div :class="$style.input">
|
||||
|
@ -239,7 +259,7 @@ async function onDrop(expression: string, event: MouseEvent) {
|
|||
.container {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: var(--spacing-s);
|
||||
gap: var(--spacing-2xs);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
WORKFLOW_EVALUATION_EXPERIMENT,
|
||||
} from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
@ -19,6 +20,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { usePushConnection } from '@/composables/usePushConnection';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
|
||||
import GithubButton from 'vue-github-button';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
@ -33,6 +35,7 @@ const sourceControlStore = useSourceControlStore();
|
|||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const posthogStore = usePostHog();
|
||||
|
||||
const activeHeaderTab = ref(MAIN_HEADER_TABS.WORKFLOW);
|
||||
const workflowToReturnTo = ref('');
|
||||
|
@ -40,10 +43,20 @@ const executionToReturnTo = ref('');
|
|||
const dirtyState = ref(false);
|
||||
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
|
||||
|
||||
const tabBarItems = computed(() => [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
|
||||
{ value: MAIN_HEADER_TABS.EXECUTIONS, label: locale.baseText('generic.executions') },
|
||||
]);
|
||||
const tabBarItems = computed(() => {
|
||||
const items = [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
|
||||
{ value: MAIN_HEADER_TABS.EXECUTIONS, label: locale.baseText('generic.executions') },
|
||||
];
|
||||
|
||||
if (posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT)) {
|
||||
items.push({
|
||||
value: MAIN_HEADER_TABS.TEST_DEFINITION,
|
||||
label: locale.baseText('generic.tests'),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const activeNode = computed(() => ndvStore.activeNode);
|
||||
const hideMenuBar = computed(() =>
|
||||
|
@ -80,6 +93,9 @@ onMounted(async () => {
|
|||
});
|
||||
|
||||
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
|
||||
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) {
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
|
||||
}
|
||||
if (
|
||||
to.name === VIEWS.EXECUTION_HOME ||
|
||||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
|
||||
|
@ -119,6 +135,11 @@ function onTabSelected(tab: MAIN_HEADER_TABS, event: MouseEvent) {
|
|||
void navigateToExecutionsView(openInNewTab);
|
||||
break;
|
||||
|
||||
case MAIN_HEADER_TABS.TEST_DEFINITION:
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
|
||||
void router.push({ name: VIEWS.TEST_DEFINITION });
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -103,6 +103,7 @@ const tagsEventBus = createEventBus();
|
|||
const sourceControlModalEventBus = createEventBus();
|
||||
|
||||
const {
|
||||
isNewUser,
|
||||
nodeViewVersion,
|
||||
nodeViewSwitcherDiscovered,
|
||||
isNodeViewDiscoveryTooltipVisible,
|
||||
|
@ -193,10 +194,14 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
|||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.SWITCH_NODE_VIEW_VERSION,
|
||||
...(nodeViewVersion.value === '2'
|
||||
? {}
|
||||
? nodeViewSwitcherDiscovered.value || isNewUser.value
|
||||
? {}
|
||||
: {
|
||||
badge: locale.baseText('menuActions.badge.new'),
|
||||
}
|
||||
: nodeViewSwitcherDiscovered.value
|
||||
? {
|
||||
badge: locale.baseText('menuActions.badge.alpha'),
|
||||
badge: locale.baseText('menuActions.badge.beta'),
|
||||
badgeProps: {
|
||||
theme: 'tertiary',
|
||||
},
|
||||
|
@ -756,9 +761,12 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
/>
|
||||
<template #content>
|
||||
<div class="mb-4xs">
|
||||
<N8nBadge>{{ i18n.baseText('menuActions.badge.alpha') }}</N8nBadge>
|
||||
<N8nBadge>{{ i18n.baseText('menuActions.badge.beta') }}</N8nBadge>
|
||||
</div>
|
||||
{{ i18n.baseText('menuActions.nodeViewDiscovery.tooltip') }}
|
||||
<p>{{ i18n.baseText('menuActions.nodeViewDiscovery.tooltip') }}</p>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('menuActions.nodeViewDiscovery.tooltip.switchBack') }}
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
:class="$style.closeNodeViewDiscovery"
|
||||
icon="times-circle"
|
||||
|
|
|
@ -8,10 +8,10 @@ import { LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH, MAIN_NODE_PANEL_WIDTH } from '
|
|||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { ndvEventBus } from '@/event-bus';
|
||||
import NDVFloatingNodes from '@/components/NDVFloatingNodes.vue';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import type { MainPanelType, XYPosition } from '@/Interface';
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
|
||||
const SIDE_MARGIN = 24;
|
||||
const SIDE_PANELS_MARGIN = 80;
|
||||
|
@ -34,7 +34,8 @@ interface Props {
|
|||
nodeType: INodeTypeDescription | null;
|
||||
}
|
||||
|
||||
const { callDebounced } = useDebounce();
|
||||
const throttledOnResize = useThrottleFn(onResize, 100);
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
|
@ -292,9 +293,9 @@ function onResizeEnd() {
|
|||
storePositionData();
|
||||
}
|
||||
|
||||
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
|
||||
function onResizeThrottle(data: { direction: string; x: number; width: number }) {
|
||||
if (initialized.value) {
|
||||
void callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
|
||||
void throttledOnResize(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -368,13 +369,13 @@ function onDragEnd() {
|
|||
<slot name="output"></slot>
|
||||
</div>
|
||||
<div :class="$style.mainPanel" :style="mainPanelStyles">
|
||||
<n8n-resize-wrapper
|
||||
<N8nResizeWrapper
|
||||
:is-resizing-enabled="currentNodePaneType !== 'unknown'"
|
||||
:width="relativeWidthToPx(mainPanelDimensions.relativeWidth)"
|
||||
:min-width="MIN_PANEL_WIDTH"
|
||||
:grid-size="20"
|
||||
:supported-directions="supportedResizeDirections"
|
||||
@resize="onResizeDebounced"
|
||||
@resize="onResizeThrottle"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<div :class="$style.dragButtonContainer">
|
||||
|
@ -391,7 +392,7 @@ function onDragEnd() {
|
|||
<div :class="{ [$style.mainPanelInner]: true, [$style.dragging]: isDragging }">
|
||||
<slot name="main" />
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
</N8nResizeWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -287,6 +287,7 @@ async function onClick() {
|
|||
const updateInformation = await generateCodeForAiTransform(
|
||||
prompt,
|
||||
`parameters.${AI_TRANSFORM_JS_CODE}`,
|
||||
5,
|
||||
);
|
||||
if (!updateInformation) return;
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import type { EventBus } from 'n8n-design-system/utils';
|
|||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
type Props = {
|
||||
parameter: INodeProperties;
|
||||
|
@ -144,7 +145,11 @@ const evaluatedExpressionValue = computed(() => {
|
|||
});
|
||||
|
||||
const evaluatedExpressionString = computed(() => {
|
||||
return stringifyExpressionResult(evaluatedExpression.value);
|
||||
const hasRunData =
|
||||
!!useWorkflowsStore().workflowExecutionData?.data?.resultData?.runData[
|
||||
ndvStore.activeNode?.name ?? ''
|
||||
];
|
||||
return stringifyExpressionResult(evaluatedExpression.value, hasRunData);
|
||||
});
|
||||
|
||||
const expressionOutput = computed(() => {
|
||||
|
|
|
@ -46,6 +46,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useNodeType } from '@/composables/useNodeType';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { PinDataSource, UnpinDataSource } from '@/composables/usePinnedData';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
@ -86,8 +87,11 @@ const LazyRunDataTable = defineAsyncComponent(
|
|||
const LazyRunDataJson = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataJson.vue'),
|
||||
);
|
||||
const LazyRunDataSchema = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataSchema.vue'),
|
||||
|
||||
const LazyRunDataSchema = defineAsyncComponent(async () =>
|
||||
useSettingsStore().settings.virtualSchemaView
|
||||
? await import('@/components/VirtualSchema.vue')
|
||||
: await import('@/components/RunDataSchema.vue'),
|
||||
);
|
||||
const LazyRunDataHtml = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataHtml.vue'),
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i18n-t keypath="settings.sourceControl.connection.error.message" tag="div">
|
||||
<template #link>
|
||||
<RouterLink :to="{ name: VIEWS.SOURCE_CONTROL }">
|
||||
{{ i18n.baseText('settings.sourceControl.connection.error.link') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
|
@ -12,11 +12,11 @@ import { useToast } from '@/composables/useToast';
|
|||
interface TagsDropdownProps {
|
||||
placeholder: string;
|
||||
modelValue: string[];
|
||||
createTag: (name: string) => Promise<ITag>;
|
||||
eventBus: EventBus | null;
|
||||
allTags: ITag[];
|
||||
isLoading: boolean;
|
||||
tagsById: Record<string, ITag>;
|
||||
createTag?: (name: string) => Promise<ITag>;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
@ -109,6 +109,8 @@ function filterOptions(value = '') {
|
|||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!props.createTag) return;
|
||||
|
||||
const name = filter.value;
|
||||
try {
|
||||
const newTag = await props.createTag(name);
|
||||
|
|
127
packages/editor-ui/src/components/Telemetry.test.ts
Normal file
127
packages/editor-ui/src/components/Telemetry.test.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
import { useRoute } from 'vue-router';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { MockedStore } from '@/__tests__/utils';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import Telemetry from './Telemetry.vue';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const meta = {};
|
||||
return {
|
||||
useRouter: vi.fn(),
|
||||
useRoute: () => ({
|
||||
meta,
|
||||
}),
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => {
|
||||
const init = vi.fn();
|
||||
return {
|
||||
useTelemetry: () => ({
|
||||
init,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(Telemetry, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
let route: ReturnType<typeof useRoute>;
|
||||
let rootStore: MockedStore<typeof useRootStore>;
|
||||
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||
let usersStore: MockedStore<typeof useUsersStore>;
|
||||
let telemetryPlugin: ReturnType<typeof useTelemetry>;
|
||||
|
||||
describe('Telemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
route = useRoute();
|
||||
rootStore = mockedStore(useRootStore);
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
usersStore = mockedStore(useUsersStore);
|
||||
telemetryPlugin = useTelemetry();
|
||||
});
|
||||
|
||||
it('should not throw error when opened', async () => {
|
||||
expect(() => renderComponent()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should initialize if telemetry is enabled in settings and not disabled on the route', async () => {
|
||||
settingsStore.telemetry = {
|
||||
enabled: true,
|
||||
};
|
||||
usersStore.currentUserId = '123';
|
||||
rootStore.instanceId = '456';
|
||||
renderComponent();
|
||||
|
||||
expect(telemetryPlugin.init).toHaveBeenCalledWith(
|
||||
{
|
||||
enabled: true,
|
||||
},
|
||||
expect.objectContaining({
|
||||
userId: '123',
|
||||
instanceId: '456',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not initialize if telemetry is disabled in settings', async () => {
|
||||
settingsStore.telemetry = {
|
||||
enabled: false,
|
||||
};
|
||||
renderComponent();
|
||||
|
||||
expect(telemetryPlugin.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize if telemetry is disabled on the route', async () => {
|
||||
settingsStore.telemetry = {
|
||||
enabled: true,
|
||||
};
|
||||
route.meta.telemetry = {
|
||||
disabled: true,
|
||||
};
|
||||
renderComponent();
|
||||
|
||||
expect(telemetryPlugin.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the iframe with correct src', async () => {
|
||||
settingsStore.telemetry = {
|
||||
enabled: true,
|
||||
};
|
||||
usersStore.currentUserId = '123';
|
||||
rootStore.instanceId = '456';
|
||||
const { container } = renderComponent();
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe).not.toBeVisible();
|
||||
expect(iframe).toHaveAttribute('src', expect.stringContaining('userId=123'));
|
||||
expect(iframe).toHaveAttribute('src', expect.stringContaining('instanceId=456'));
|
||||
});
|
||||
|
||||
it('should not render the iframe if telemetry disabled', async () => {
|
||||
settingsStore.telemetry = {
|
||||
enabled: false,
|
||||
};
|
||||
usersStore.currentUserId = '123';
|
||||
rootStore.instanceId = '456';
|
||||
const { container } = renderComponent();
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
|
||||
expect(iframe).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div :class="$style.arrowConnector"></div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.arrowConnector {
|
||||
$arrow-width: 12px;
|
||||
$arrow-height: 8px;
|
||||
$stalk-width: 2px;
|
||||
$color: var(--color-text-dark);
|
||||
|
||||
position: relative;
|
||||
height: var(--arrow-height, 3rem);
|
||||
margin: 0.5rem 0;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
width: $stalk-width;
|
||||
height: calc(100% - #{$arrow-height});
|
||||
background-color: $color;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: calc($arrow-width / 2) solid transparent;
|
||||
border-right: calc($arrow-width / 2) solid transparent;
|
||||
border-top: $arrow-height solid $color;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
});
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.description]">
|
||||
<n8n-input-label
|
||||
:label="locale.baseText('testDefinition.edit.description')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
:class="$style.field"
|
||||
>
|
||||
<N8nInput
|
||||
:model-value="modelValue"
|
||||
type="textarea"
|
||||
:placeholder="locale.baseText('testDefinition.edit.descriptionPlaceholder')"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.field {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,100 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export interface EvaluationHeaderProps {
|
||||
modelValue: {
|
||||
value: string;
|
||||
isEditing: boolean;
|
||||
tempValue: string;
|
||||
};
|
||||
startEditing: (field: string) => void;
|
||||
saveChanges: (field: string) => void;
|
||||
handleKeydown: (e: KeyboardEvent, field: string) => void;
|
||||
}
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
|
||||
defineProps<EvaluationHeaderProps>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.header">
|
||||
<n8n-icon-button
|
||||
icon="arrow-left"
|
||||
:class="$style.backButton"
|
||||
type="tertiary"
|
||||
:title="locale.baseText('testDefinition.edit.backButtonTitle')"
|
||||
@click="$router.back()"
|
||||
/>
|
||||
<h2 :class="$style.title">
|
||||
<template v-if="!modelValue.isEditing">
|
||||
<span :class="$style.titleText">
|
||||
{{ modelValue.value }}
|
||||
</span>
|
||||
<n8n-icon-button
|
||||
:class="$style.editInputButton"
|
||||
icon="pen"
|
||||
type="tertiary"
|
||||
@click="startEditing('name')"
|
||||
/>
|
||||
</template>
|
||||
<N8nInput
|
||||
v-else
|
||||
ref="nameInput"
|
||||
data-test-id="evaluation-name-input"
|
||||
:model-value="modelValue.tempValue"
|
||||
type="text"
|
||||
:placeholder="locale.baseText('testDefinition.edit.namePlaceholder')"
|
||||
@update:model-value="$emit('update:modelValue', { ...modelValue, tempValue: $event })"
|
||||
@blur="() => saveChanges('name')"
|
||||
@keydown="(e: KeyboardEvent) => handleKeydown(e, 'name')"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
margin-bottom: var(--spacing-l);
|
||||
|
||||
&:hover {
|
||||
.editInputButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.titleText {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.editInputButton {
|
||||
--button-font-color: var(--prim-gray-490);
|
||||
opacity: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
--button-font-color: var(--color-text-light);
|
||||
border: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,142 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ElCollapseTransition } from 'element-plus';
|
||||
import { ref, nextTick } from 'vue';
|
||||
|
||||
interface EvaluationStep {
|
||||
title: string;
|
||||
warning?: boolean;
|
||||
small?: boolean;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||
description: '',
|
||||
warning: false,
|
||||
small: false,
|
||||
expanded: true,
|
||||
});
|
||||
|
||||
const locale = useI18n();
|
||||
const isExpanded = ref(props.expanded);
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const toggleExpand = async () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
if (isExpanded.value) {
|
||||
await nextTick();
|
||||
if (containerRef.value) {
|
||||
containerRef.value.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]">
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.header">
|
||||
<div :class="[$style.icon, warning && $style.warning]">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<h3 :class="$style.title">{{ title }}</h3>
|
||||
<span v-if="warning" :class="$style.warningIcon">⚠</span>
|
||||
<button
|
||||
v-if="$slots.cardContent"
|
||||
:class="$style.collapseButton"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
{{
|
||||
isExpanded
|
||||
? locale.baseText('testDefinition.edit.step.collapse')
|
||||
: locale.baseText('testDefinition.edit.step.expand')
|
||||
}}
|
||||
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
|
||||
</button>
|
||||
</div>
|
||||
<ElCollapseTransition v-if="$slots.cardContent">
|
||||
<div v-show="isExpanded" :class="$style.cardContentWrapper">
|
||||
<div ref="contentRef" :class="$style.cardContent">
|
||||
<slot name="cardContent" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseTransition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.evaluationStep {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-m);
|
||||
background: var(--color-background-light);
|
||||
padding: var(--spacing-s);
|
||||
border-radius: var(--border-radius-xlarge);
|
||||
box-shadow: var(--box-shadow-base);
|
||||
border: var(--border-base);
|
||||
width: 100%;
|
||||
color: var(--color-text-dark);
|
||||
|
||||
&.small {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
||||
&.warning {
|
||||
background-color: var(--color-warning-tint-2);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-s);
|
||||
line-height: 1.125rem;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
font-size: var(--font-size-s);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
.collapseButton {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-3xs);
|
||||
color: var(--color-text-base);
|
||||
margin-left: auto;
|
||||
text-wrap: none;
|
||||
overflow: hidden;
|
||||
min-width: fit-content;
|
||||
}
|
||||
.cardContentWrapper {
|
||||
height: max-content;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export interface MetricsInputProps {
|
||||
modelValue: string[];
|
||||
}
|
||||
const props = defineProps<MetricsInputProps>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
|
||||
const locale = useI18n();
|
||||
|
||||
function addNewMetric() {
|
||||
emit('update:modelValue', [...props.modelValue, '']);
|
||||
}
|
||||
|
||||
function updateMetric(index: number, value: string) {
|
||||
const newMetrics = [...props.modelValue];
|
||||
newMetrics[index] = value;
|
||||
emit('update:modelValue', newMetrics);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.metrics]">
|
||||
<n8n-input-label
|
||||
:label="locale.baseText('testDefinition.edit.metricsFields')"
|
||||
:bold="false"
|
||||
:class="$style.metricField"
|
||||
>
|
||||
<div :class="$style.metricsContainer">
|
||||
<div v-for="(metric, index) in modelValue" :key="index">
|
||||
<N8nInput
|
||||
:ref="`metric_${index}`"
|
||||
data-test-id="evaluation-metric-item"
|
||||
:model-value="metric"
|
||||
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
|
||||
@update:model-value="(value: string) => updateMetric(index, value)"
|
||||
/>
|
||||
</div>
|
||||
<n8n-button
|
||||
type="tertiary"
|
||||
:label="locale.baseText('testDefinition.edit.metricsNew')"
|
||||
:class="$style.newMetricButton"
|
||||
@click="addNewMetric"
|
||||
/>
|
||||
</div>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.metricsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.metricField {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.metricsDivider {
|
||||
margin-top: var(--spacing-4xs);
|
||||
margin-bottom: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.newMetricButton {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--spacing-2xs);
|
||||
width: 100%;
|
||||
background-color: var(--color-sticky-code-background);
|
||||
border-color: var(--color-button-secondary-focus-outline);
|
||||
color: var(--color-button-secondary-font);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ITag } from '@/Interface';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface TagsInputProps {
|
||||
modelValue?: {
|
||||
isEditing: boolean;
|
||||
appliedTagIds: string[];
|
||||
};
|
||||
allTags: ITag[];
|
||||
tagsById: Record<string, ITag>;
|
||||
isLoading: boolean;
|
||||
startEditing: (field: string) => void;
|
||||
saveChanges: (field: string) => void;
|
||||
cancelEditing: (field: string) => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TagsInputProps>(), {
|
||||
modelValue: () => ({
|
||||
isEditing: false,
|
||||
appliedTagIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const tagsEventBus = createEventBus();
|
||||
const getTagName = computed(() => (tagId: string) => {
|
||||
return props.tagsById[tagId]?.name ?? '';
|
||||
});
|
||||
|
||||
function updateTags(tags: string[]) {
|
||||
const newTags = tags[0] ? [tags[0]] : [];
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
appliedTagIds: newTags,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="workflow-tags-field">
|
||||
<n8n-input-label
|
||||
:label="locale.baseText('testDefinition.edit.tagName')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
|
||||
<n8n-text v-if="modelValue.appliedTagIds.length === 0" size="small">
|
||||
{{ locale.baseText('testDefinition.edit.selectTag') }}
|
||||
</n8n-text>
|
||||
<n8n-tag
|
||||
v-for="tagId in modelValue.appliedTagIds"
|
||||
:key="tagId"
|
||||
:text="getTagName(tagId)"
|
||||
data-test-id="evaluation-tag-field"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
:class="$style.editInputButton"
|
||||
icon="pen"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
transparent
|
||||
/>
|
||||
</div>
|
||||
<TagsDropdown
|
||||
v-else
|
||||
:model-value="modelValue.appliedTagIds"
|
||||
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
|
||||
:create-enabled="false"
|
||||
:all-tags="allTags"
|
||||
:is-loading="isLoading"
|
||||
:tags-by-id="tagsById"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
:event-bus="tagsEventBus"
|
||||
@update:model-value="updateTags"
|
||||
@esc="cancelEditing('tags')"
|
||||
@blur="saveChanges('tags')"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
<n8n-text size="small" color="text-light">{{
|
||||
locale.baseText('testDefinition.edit.tagsHelpText')
|
||||
}}</n8n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.tagsRead {
|
||||
&:hover .editInputButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editInputButton {
|
||||
opacity: 0;
|
||||
border: none;
|
||||
--button-font-color: var(--prim-gray-490);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
interface WorkflowSelectorProps {
|
||||
modelValue: INodeParameterResourceLocator;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<WorkflowSelectorProps>(), {
|
||||
modelValue: () => ({
|
||||
mode: 'id',
|
||||
value: '',
|
||||
__rl: true,
|
||||
}),
|
||||
});
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: WorkflowSelectorProps['modelValue']] }>();
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<n8n-input-label
|
||||
:label="locale.baseText('testDefinition.edit.workflowSelectorLabel')"
|
||||
:bold="false"
|
||||
>
|
||||
<WorkflowSelectorParameterInput
|
||||
ref="workflowInput"
|
||||
:parameter="{
|
||||
displayName: locale.baseText('testDefinition.edit.workflowSelectorDisplayName'),
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
default: '',
|
||||
}"
|
||||
:model-value="modelValue"
|
||||
:display-title="locale.baseText('testDefinition.edit.workflowSelectorTitle')"
|
||||
:is-value-expression="false"
|
||||
:expression-edit-dialog-visible="false"
|
||||
:path="'workflows'"
|
||||
allow-new
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
defineEmits<{ 'create-test': [] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<h1>{{ locale.baseText('testDefinition.list.tests') }}</h1>
|
||||
</div>
|
||||
<n8n-action-box
|
||||
:description="locale.baseText('testDefinition.list.actionDescription')"
|
||||
:button-text="locale.baseText('testDefinition.list.actionButton')"
|
||||
@click:button="$emit('create-test')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
max-width: 44rem;
|
||||
margin: var(--spacing-4xl) auto 0;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--color-text-dark);
|
||||
font-size: var(--font-size-l);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue