Merge remote-tracking branch 'origin/master' into pay-1852-public-api-delete-users-from-project

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-01-13 13:34:23 +01:00
commit 79d347fb23
No known key found for this signature in database
82 changed files with 3001 additions and 616 deletions

View file

@ -20,26 +20,28 @@ jobs:
- uses: actions/checkout@v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-base/Dockerfile

View file

@ -19,20 +19,22 @@ jobs:
- uses: actions/checkout@v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./packages/@n8n/benchmark/Dockerfile

View file

@ -0,0 +1,83 @@
name: Docker Custom Image CI
run-name: Build ${{ inputs.branch }} - ${{ inputs.user }}
on:
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to create image off.'
required: true
tag:
description: 'Name of the docker tag to create.'
required: true
merge-master:
description: 'Merge with master.'
type: boolean
required: true
default: false
user:
description: ''
required: false
default: 'none'
start-url:
description: 'URL to call after workflow is kicked off.'
required: false
default: ''
success-url:
description: 'URL to call after Docker Image got built successfully.'
required: false
default: ''
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Call Start URL - optionally
if: ${{ github.event.inputs.start-url != '' }}
run: curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ${{github.event.inputs.start-url}} || echo ""
shell: bash
- name: Checkout
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: Merge Master - optionally
if: github.event.inputs.merge-master
run: git remote add upstream https://github.com/n8n-io/n8n.git -f; git merge upstream/master --allow-unrelated-histories || echo ""
shell: bash
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GHCR
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image to GHCR
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-custom/Dockerfile
build-args: |
N8N_RELEASE_TYPE=development
platforms: linux/amd64
provenance: false
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/${{ github.repository_owner }}/n8n:${{ inputs.tag }}
- name: Call Success URL - optionally
if: ${{ github.event.inputs.success-url != '' }}
run: curl -v ${{github.event.inputs.success-url}} || echo ""
shell: bash

View file

@ -1,74 +1,42 @@
name: Docker Nightly Image CI
run-name: Build ${{ inputs.branch }} - ${{ inputs.user }}
on:
schedule:
- cron: '0 1 * * *'
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to create image off.'
required: true
default: 'master'
tag:
description: 'Name of the docker tag to create.'
required: true
default: 'nightly'
merge-master:
description: 'Merge with master.'
type: boolean
required: true
default: false
user:
description: ''
required: false
default: 'schedule'
start-url:
description: 'URL to call after workflow is kicked off.'
required: false
default: ''
success-url:
description: 'URL to call after Docker Image got built successfully.'
required: false
default: ''
env:
N8N_TAG: ${{ inputs.tag || 'nightly' }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Call Start URL - optionally
run: |
[[ "${{github.event.inputs.start-url}}" != "" ]] && curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ${{github.event.inputs.start-url}} || echo ""
shell: bash
- name: Checkout
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch || 'master' }}
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GHCR
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Merge Master - optionally
run: |
[[ "${{github.event.inputs.merge-master}}" == "true" ]] && git remote add upstream https://github.com/n8n-io/n8n.git -f; git merge upstream/master --allow-unrelated-histories || echo ""
shell: bash
- name: Build and push to DockerHub
uses: docker/build-push-action@v5.1.0
- name: Build and push image to GHCR and DockerHub
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-custom/Dockerfile
@ -79,24 +47,6 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ secrets.DOCKER_USERNAME }}/n8n:${{ env.N8N_TAG }}
- name: Login to GitHub Container Registry
if: env.N8N_TAG == 'nightly'
uses: docker/login-action@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image to GHCR
if: env.N8N_TAG == 'nightly'
run: |
docker buildx imagetools create \
--tag ghcr.io/${{ github.repository_owner }}/n8n:nightly \
tags: |
ghcr.io/${{ github.repository_owner }}/n8n:nightly
${{ secrets.DOCKER_USERNAME }}/n8n:nightly
- name: Call Success URL - optionally
run: |
[[ "${{github.event.inputs.success-url}}" != "" ]] && curl -v ${{github.event.inputs.success-url}} || echo ""
shell: bash

View file

@ -73,26 +73,28 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: ./docker/images/n8n
build-args: |

View file

@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: docker/login-action@v3.0.0
- uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: docker/login-action@v3.0.0
- uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View file

@ -250,7 +250,7 @@ describe('Webhook Trigger node', () => {
});
// add credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.actions.fillCredentialsForm();
@ -293,7 +293,7 @@ describe('Webhook Trigger node', () => {
});
// add credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.actions.fillCredentialsForm();

View file

@ -297,10 +297,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the credential in this project (+ the 'Create new' option) should
// be in the dropdown
// Only the credential in this project should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2);
getVisibleSelect().find('li').should('have.length', 1);
});
it('should only show credentials in their personal project for members', () => {
@ -325,10 +324,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one (+ the 'Create new' option)
// should be in the dropdown
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
getVisibleSelect().find('li').should('have.length', 2);
});
it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
@ -355,10 +353,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one (+ the 'Create new' option)
// should be in the dropdown
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2);
getVisibleSelect().find('li').should('have.length', 1);
});
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
@ -400,10 +397,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the personal credentials of the workflow owner and the global owner
// should show up.
// Only the personal credentials of the workflow owner and the global owner should show up.
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 4);
getVisibleSelect().find('li').should('have.length', 3);
});
it('should show all personal credentials if the global owner owns the workflow', () => {
@ -421,6 +417,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
// Show all personal credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.have.length', 2);
getVisibleSelect().find('li').should('have.have.length', 1);
});
});

View file

@ -31,7 +31,7 @@ function createNotionCredential() {
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
workflowPage.actions.openNode(NOTION_NODE_NAME);
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
cy.get('body').type('{esc}');
workflowPage.actions.deleteNode(NOTION_NODE_NAME);
@ -79,7 +79,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
@ -99,7 +99,7 @@ describe('Credentials', () => {
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
// Add oAuth credentials
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
@ -107,14 +107,13 @@ describe('Credentials', () => {
cy.get('.el-message-box').find('button').contains('Close').click();
workflowPage.getters.nodeCredentialsSelect().click();
// Add Service account credentials
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().last().click();
credentialsModal.actions.fillCredentialsForm();
// Both (+ the 'Create new' option) should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length.greaterThan', 2);
getVisibleSelect().find('li').should('have.length', 3);
});
it('should correctly render required and optional credentials', () => {
@ -130,13 +129,13 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('have.length', 2);
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect().find('li').contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().first().click();
// This one should show auth type selector
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
cy.get('body').type('{esc}');
workflowPage.getters.nodeCredentialsSelect().last().click();
getVisibleSelect().find('li').contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().last().click();
// This one should not show auth type selector
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
});
@ -148,7 +147,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters
@ -164,7 +163,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters
.nodeCredentialsSelect()
@ -189,7 +188,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
@ -232,7 +231,7 @@ describe('Credentials', () => {
cy.getByTestId('credential-select').click();
cy.contains('Adalo API').click();
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
@ -296,7 +295,7 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('exist');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters
.nodeCredentialsSelect()
@ -325,7 +324,7 @@ describe('Credentials', () => {
workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
workflowPage.getters.nodeCredentialsSelect().should('exist');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
nodeDetailsView.getters.copyInput().should('not.exist');
});

View file

@ -89,7 +89,7 @@ describe('Community and custom nodes in canvas', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API');
});
@ -98,7 +98,7 @@ describe('Community and custom nodes in canvas', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential');
});

View file

@ -367,7 +367,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click();
@ -382,7 +382,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click();
@ -396,7 +396,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click();
@ -407,7 +407,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click();
@ -425,7 +425,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account personal project');
ndv.getters.backToCanvas().click();
@ -436,7 +436,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account personal project');
});

View file

@ -5,7 +5,6 @@ import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { AIAssistant } from '../pages/features/ai-assistant';
import { NodeCreator } from '../pages/features/node-creator';
import { getVisibleSelect } from '../utils';
const wf = new WorkflowPage();
const ndv = new NDV();
@ -434,7 +433,7 @@ describe('AI Assistant Credential Help', () => {
wf.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
wf.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
@ -467,7 +466,7 @@ describe('AI Assistant Credential Help', () => {
wf.actions.addNodeToCanvas('Microsoft Outlook', true, true, 'Get a calendar');
wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
wf.getters.nodeCredentialsCreateOption().click();
ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
credentialsModal.getters.credentialInputs().should('have.length', 1);

View file

@ -73,7 +73,7 @@ docker run -it --rm \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
docker.n8n.io/n8nio/n8n \
n8n start --tunnel
start --tunnel
```
## Persist data

View file

@ -90,6 +90,7 @@
"ws": ">=8.17.1"
},
"patchedDependencies": {
"bull@4.12.1": "patches/bull@4.12.1.patch",
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch",
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",

View file

@ -0,0 +1,178 @@
import type { ErrorEvent } from '@sentry/types';
import { mock } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
import { TaskRunnerSentry } from '../task-runner-sentry';
describe('TaskRunnerSentry', () => {
afterEach(() => {
jest.resetAllMocks();
});
describe('filterOutUserCodeErrors', () => {
const sentry = new TaskRunnerSentry(
{
dsn: 'https://sentry.io/123',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mock(),
);
it('should filter out user code errors', () => {
const event: ErrorEvent = {
type: undefined,
exception: {
values: [
{
type: 'ReferenceError',
value: 'fetch is not defined',
stacktrace: {
frames: [
{
filename: 'app:///dist/js-task-runner/js-task-runner.js',
module: 'js-task-runner:js-task-runner',
function: 'JsTaskRunner.executeTask',
},
{
filename: 'app:///dist/js-task-runner/js-task-runner.js',
module: 'js-task-runner:js-task-runner',
function: 'JsTaskRunner.runForAllItems',
},
{
filename: '<anonymous>',
module: '<anonymous>',
function: 'new Promise',
},
{
filename: 'app:///dist/js-task-runner/js-task-runner.js',
module: 'js-task-runner:js-task-runner',
function: 'result',
},
{
filename: 'node:vm',
module: 'node:vm',
function: 'runInContext',
},
{
filename: 'node:vm',
module: 'node:vm',
function: 'Script.runInContext',
},
{
filename: 'evalmachine.<anonymous>',
module: 'evalmachine.<anonymous>',
function: '?',
},
{
filename: 'evalmachine.<anonymous>',
module: 'evalmachine.<anonymous>',
function: 'VmCodeWrapper',
},
{
filename: '<anonymous>',
module: '<anonymous>',
function: 'new Promise',
},
{
filename: 'evalmachine.<anonymous>',
module: 'evalmachine.<anonymous>',
},
],
},
mechanism: { type: 'onunhandledrejection', handled: false },
},
],
},
event_id: '18bb78bb3d9d44c4acf3d774c2cfbfd8',
platform: 'node',
contexts: {
trace: { trace_id: '3c3614d33a6b47f09b85ec7d2710acea', span_id: 'ad00fdf6d6173aeb' },
runtime: { name: 'node', version: 'v20.17.0' },
},
};
expect(sentry.filterOutUserCodeErrors(event)).toBe(true);
});
});
describe('initIfEnabled', () => {
const mockErrorReporter = mock<ErrorReporter>();
it('should not configure sentry if dsn is not set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: '',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.initIfEnabled();
expect(mockErrorReporter.init).not.toHaveBeenCalled();
});
it('should configure sentry if dsn is set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: 'https://sentry.io/123',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.initIfEnabled();
expect(mockErrorReporter.init).toHaveBeenCalledWith({
dsn: 'https://sentry.io/123',
beforeSendFilter: sentry.filterOutUserCodeErrors,
release: '1.0.0',
environment: 'local',
serverName: 'test',
serverType: 'task_runner',
});
});
});
describe('shutdown', () => {
const mockErrorReporter = mock<ErrorReporter>();
it('should not shutdown sentry if dsn is not set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: '',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.shutdown();
expect(mockErrorReporter.shutdown).not.toHaveBeenCalled();
});
it('should shutdown sentry if dsn is set', async () => {
const sentry = new TaskRunnerSentry(
{
dsn: 'https://sentry.io/123',
n8nVersion: '1.0.0',
environment: 'local',
deploymentName: 'test',
},
mockErrorReporter,
);
await sentry.shutdown();
expect(mockErrorReporter.shutdown).toHaveBeenCalled();
});
});
});

View file

@ -1,16 +1,16 @@
import './polyfills';
import { Container } from '@n8n/di';
import type { ErrorReporter } from 'n8n-core';
import { ensureError, setGlobalState } from 'n8n-workflow';
import { MainConfig } from './config/main-config';
import type { HealthCheckServer } from './health-check-server';
import { JsTaskRunner } from './js-task-runner/js-task-runner';
import { TaskRunnerSentry } from './task-runner-sentry';
let healthCheckServer: HealthCheckServer | undefined;
let runner: JsTaskRunner | undefined;
let isShuttingDown = false;
let errorReporter: ErrorReporter | undefined;
let sentry: TaskRunnerSentry | undefined;
function createSignalHandler(signal: string, timeoutInS = 10) {
return async function onSignal() {
@ -33,9 +33,9 @@ function createSignalHandler(signal: string, timeoutInS = 10) {
void healthCheckServer?.stop();
}
if (errorReporter) {
await errorReporter.shutdown();
errorReporter = undefined;
if (sentry) {
await sentry.shutdown();
sentry = undefined;
}
} catch (e) {
const error = ensureError(e);
@ -54,20 +54,8 @@ void (async function start() {
defaultTimezone: config.baseRunnerConfig.timezone,
});
const { dsn } = config.sentryConfig;
if (dsn) {
const { ErrorReporter } = await import('n8n-core');
errorReporter = Container.get(ErrorReporter);
const { deploymentName, environment, n8nVersion } = config.sentryConfig;
await errorReporter.init({
serverType: 'task_runner',
dsn,
serverName: deploymentName,
environment,
release: n8nVersion,
});
}
sentry = Container.get(TaskRunnerSentry);
await sentry.initIfEnabled();
runner = new JsTaskRunner(config);
runner.on('runner:reached-idle-timeout', () => {

View file

@ -0,0 +1,62 @@
import { Service } from '@n8n/di';
import type { ErrorEvent, Exception } from '@sentry/types';
import { ErrorReporter } from 'n8n-core';
import { SentryConfig } from './config/sentry-config';
/**
* Sentry service for the task runner.
*/
@Service()
export class TaskRunnerSentry {
constructor(
private readonly config: SentryConfig,
private readonly errorReporter: ErrorReporter,
) {}
async initIfEnabled() {
const { dsn, n8nVersion, environment, deploymentName } = this.config;
if (!dsn) return;
await this.errorReporter.init({
serverType: 'task_runner',
dsn,
release: n8nVersion,
environment,
serverName: deploymentName,
beforeSendFilter: this.filterOutUserCodeErrors,
});
}
async shutdown() {
if (!this.config.dsn) return;
await this.errorReporter.shutdown();
}
/**
* Filter out errors originating from user provided code.
* It is possible for users to create code that causes unhandledrejections
* that end up in the sentry error reporting.
*/
filterOutUserCodeErrors = (event: ErrorEvent) => {
const error = event?.exception?.values?.[0];
return error ? this.isUserCodeError(error) : false;
};
/**
* Check if the error is originating from user provided code.
* It is possible for users to create code that causes unhandledrejections
* that end up in the sentry error reporting.
*/
private isUserCodeError(error: Exception) {
const frames = error.stacktrace?.frames;
if (!frames) return false;
return frames.some(
(frame) => frame.filename === 'node:vm' && frame.function === 'runInContext',
);
}
}

View file

@ -349,15 +349,6 @@ export const schema = {
},
},
sourceControl: {
defaultKeyPairType: {
doc: 'Default SSH key type to use when generating SSH keys',
format: ['rsa', 'ed25519'] as const,
default: 'ed25519',
env: 'N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE',
},
},
workflowHistory: {
enabled: {
doc: 'Whether to save workflow history versions',

View file

@ -1,86 +1,261 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import mock from 'jest-mock-extended/lib/Mock';
import { mock, captor } from 'jest-mock-extended';
import { Cipher, type InstanceSettings } from 'n8n-core';
import { ApplicationError, deepCopy } from 'n8n-workflow';
import fsp from 'node:fs/promises';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { mockInstance } from '@test/mocking';
import type { SharedWorkflow } from '@/databases/entities/shared-workflow';
import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import type { TagRepository } from '@/databases/repositories/tag.repository';
import type { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { VariablesService } from '../../variables/variables.service.ee';
import { SourceControlExportService } from '../source-control-export.service.ee';
// https://github.com/jestjs/jest/issues/4715
function deepSpyOn<O extends object, M extends keyof O>(object: O, methodName: M) {
const spy = jest.fn();
const originalMethod = object[methodName];
if (typeof originalMethod !== 'function') {
throw new ApplicationError(`${methodName.toString()} is not a function`, { level: 'warning' });
}
object[methodName] = function (...args: unknown[]) {
const clonedArgs = deepCopy(args);
spy(...clonedArgs);
return originalMethod.apply(this, args);
} as O[M];
return spy;
}
describe('SourceControlExportService', () => {
const cipher = Container.get(Cipher);
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const workflowRepository = mock<WorkflowRepository>();
const tagRepository = mock<TagRepository>();
const workflowTagMappingRepository = mock<WorkflowTagMappingRepository>();
const variablesService = mock<VariablesService>();
const service = new SourceControlExportService(
mock(),
mock(),
mock(),
mock<InstanceSettings>({ n8nFolder: '' }),
variablesService,
tagRepository,
sharedCredentialsRepository,
sharedWorkflowRepository,
workflowRepository,
workflowTagMappingRepository,
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
);
describe('exportCredentialsToWorkFolder', () => {
it('should export credentials to work folder', async () => {
/**
* Arrange
*/
// @ts-expect-error Private method
const replaceSpy = deepSpyOn(service, 'replaceCredentialData');
const fsWriteFile = jest.spyOn(fsp, 'writeFile');
mockInstance(SharedCredentialsRepository).findByCredentialIds.mockResolvedValue([
beforeEach(() => jest.clearAllMocks());
describe('exportCredentialsToWorkFolder', () => {
const credentialData = {
authUrl: 'test',
accessTokenUrl: 'test',
clientId: 'test',
clientSecret: 'test',
oauthTokenData: {
access_token: 'test',
token_type: 'test',
expires_in: 123,
refresh_token: 'test',
},
};
const mockCredentials = mock({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
data: cipher.encrypt(credentialData),
});
it('should export credentials to work folder', async () => {
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
mock<SharedCredentials>({
credentials: mock<CredentialsEntity>({
data: Container.get(Cipher).encrypt(
JSON.stringify({
authUrl: 'test',
accessTokenUrl: 'test',
clientId: 'test',
clientSecret: 'test',
oauthTokenData: {
access_token: 'test',
token_type: 'test',
expires_in: 123,
refresh_token: 'test',
},
}),
),
credentials: mockCredentials,
project: mock({
type: 'personal',
projectRelations: [
{
role: 'project:personalOwner',
user: mock({ email: 'user@example.com' }),
},
],
}),
}),
]);
/**
* Act
*/
await service.exportCredentialsToWorkFolder([mock<SourceControlledFile>()]);
// Act
const result = await service.exportCredentialsToWorkFolder([mock()]);
/**
* Assert
*/
expect(replaceSpy).toHaveBeenCalledWith({
authUrl: 'test',
accessTokenUrl: 'test',
clientId: 'test',
clientSecret: 'test',
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
const dataCaptor = captor<string>();
expect(fsWriteFile).toHaveBeenCalledWith(
'/mock/n8n/git/credential_stubs/cred1.json',
dataCaptor,
);
expect(JSON.parse(dataCaptor.value)).toEqual({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
data: {
authUrl: '',
accessTokenUrl: '',
clientId: '',
clientSecret: '',
},
ownedBy: {
type: 'personal',
personalEmail: 'user@example.com',
},
});
});
it('should handle team project credentials', async () => {
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
mock<SharedCredentials>({
credentials: mockCredentials,
project: mock({
type: 'team',
id: 'team1',
name: 'Test Team',
}),
}),
]);
// Act
const result = await service.exportCredentialsToWorkFolder([
mock<SourceControlledFile>({ id: 'cred1' }),
]);
// Assert
expect(result.count).toBe(1);
const dataCaptor = captor<string>();
expect(fsWriteFile).toHaveBeenCalledWith(
'/mock/n8n/git/credential_stubs/cred1.json',
dataCaptor,
);
expect(JSON.parse(dataCaptor.value)).toEqual({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
data: {
authUrl: '',
accessTokenUrl: '',
clientId: '',
clientSecret: '',
},
ownedBy: {
type: 'team',
teamId: 'team1',
teamName: 'Test Team',
},
});
});
it('should handle missing credentials', async () => {
// Arrange
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([]);
// Act
const result = await service.exportCredentialsToWorkFolder([
mock<SourceControlledFile>({ id: 'cred1' }),
]);
// Assert
expect(result.missingIds).toHaveLength(1);
expect(result.missingIds?.[0]).toBe('cred1');
});
});
describe('exportTagsToWorkFolder', () => {
it('should export tags to work folder', async () => {
// Arrange
tagRepository.find.mockResolvedValue([mock()]);
workflowTagMappingRepository.find.mockResolvedValue([mock()]);
// Act
const result = await service.exportTagsToWorkFolder();
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
});
it('should not export empty tags', async () => {
// Arrange
tagRepository.find.mockResolvedValue([]);
// Act
const result = await service.exportTagsToWorkFolder();
// Assert
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
});
describe('exportVariablesToWorkFolder', () => {
it('should export variables to work folder', async () => {
// Arrange
variablesService.getAllCached.mockResolvedValue([mock()]);
// Act
const result = await service.exportVariablesToWorkFolder();
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
});
it('should not export empty variables', async () => {
// Arrange
variablesService.getAllCached.mockResolvedValue([]);
// Act
const result = await service.exportVariablesToWorkFolder();
// Assert
expect(result.count).toBe(0);
expect(result.files).toHaveLength(0);
});
});
describe('exportWorkflowsToWorkFolder', () => {
it('should export workflows to work folder', async () => {
// Arrange
workflowRepository.findByIds.mockResolvedValue([mock()]);
sharedWorkflowRepository.findByWorkflowIds.mockResolvedValue([
mock<SharedWorkflow>({
project: mock({
type: 'personal',
projectRelations: [{ role: 'project:personalOwner', user: mock() }],
}),
workflow: mock(),
}),
]);
// Act
const result = await service.exportWorkflowsToWorkFolder([mock()]);
// Assert
expect(result.count).toBe(1);
expect(result.files).toHaveLength(1);
});
it('should throw an error if workflow has no owner', async () => {
// Arrange
sharedWorkflowRepository.findByWorkflowIds.mockResolvedValue([
mock<SharedWorkflow>({
project: mock({
type: 'personal',
projectRelations: [],
}),
workflow: mock({
display: () => 'TestWorkflow',
}),
}),
]);
// Act & Assert
await expect(service.exportWorkflowsToWorkFolder([mock()])).rejects.toThrow(
'Workflow TestWorkflow has no owner',
);
});
});
});

View file

@ -1,6 +1,7 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { constants as fsConstants, accessSync } from 'fs';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import path from 'path';
@ -16,10 +17,8 @@ import {
getTrackingInformationFromPullResult,
sourceControlFoldersExistCheck,
} from '@/environments.ee/source-control/source-control-helper.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import type { SourceControlPreferences } from '@/environments.ee/source-control/types/source-control-preferences';
import { License } from '@/license';
import { mockInstance } from '@test/mocking';
import type { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import type { License } from '@/license';
const pushResult: SourceControlledFile[] = [
{
@ -151,12 +150,13 @@ const pullResult: SourceControlledFile[] = [
},
];
const license = mockInstance(License);
const license = mock<License>();
const sourceControlPreferencesService = mock<SourceControlPreferencesService>();
beforeAll(async () => {
jest.resetAllMocks();
license.isSourceControlLicensed.mockReturnValue(true);
Container.get(SourceControlPreferencesService).getPreferences = () => ({
sourceControlPreferencesService.getPreferences.mockReturnValue({
branchName: 'main',
connected: true,
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
@ -245,17 +245,4 @@ describe('Source Control', () => {
workflowUpdates: 3,
});
});
it('should class validate correct preferences', async () => {
const validPreferences: Partial<SourceControlPreferences> = {
branchName: 'main',
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
branchReadOnly: false,
branchColor: '#5296D6',
};
const validationResult = await Container.get(
SourceControlPreferencesService,
).validateSourceControlPreferences(validPreferences);
expect(validationResult).toBeTruthy();
});
});

View file

@ -0,0 +1,180 @@
import * as fastGlob from 'fast-glob';
import { mock } from 'jest-mock-extended';
import { type InstanceSettings } from 'n8n-core';
import fsp from 'node:fs/promises';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { SourceControlImportService } from '../source-control-import.service.ee';
jest.mock('fast-glob');
describe('SourceControlImportService', () => {
const workflowRepository = mock<WorkflowRepository>();
const service = new SourceControlImportService(
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
workflowRepository,
mock(),
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
);
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
beforeEach(() => jest.clearAllMocks());
describe('getRemoteVersionIdsFromFiles', () => {
const mockWorkflowFile = '/mock/workflow1.json';
it('should parse workflow files correctly', async () => {
globMock.mockResolvedValue([mockWorkflowFile]);
const mockWorkflowData = {
id: 'workflow1',
versionId: 'v1',
name: 'Test Workflow',
};
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
const result = await service.getRemoteVersionIdsFromFiles();
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
id: 'workflow1',
versionId: 'v1',
name: 'Test Workflow',
}),
);
});
it('should filter out files without valid workflow data', async () => {
globMock.mockResolvedValue(['/mock/invalid.json']);
fsReadFile.mockResolvedValue('{}');
const result = await service.getRemoteVersionIdsFromFiles();
expect(result).toHaveLength(0);
});
});
describe('getRemoteCredentialsFromFiles', () => {
it('should parse credential files correctly', async () => {
globMock.mockResolvedValue(['/mock/credential1.json']);
const mockCredentialData = {
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
};
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData));
const result = await service.getRemoteCredentialsFromFiles();
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
id: 'cred1',
name: 'Test Credential',
type: 'oauth2',
}),
);
});
it('should filter out files without valid credential data', async () => {
globMock.mockResolvedValue(['/mock/invalid.json']);
fsReadFile.mockResolvedValue('{}');
const result = await service.getRemoteCredentialsFromFiles();
expect(result).toHaveLength(0);
});
});
describe('getRemoteVariablesFromFile', () => {
it('should parse variables file correctly', async () => {
globMock.mockResolvedValue(['/mock/variables.json']);
const mockVariablesData = [
{ key: 'VAR1', value: 'value1' },
{ key: 'VAR2', value: 'value2' },
];
fsReadFile.mockResolvedValue(JSON.stringify(mockVariablesData));
const result = await service.getRemoteVariablesFromFile();
expect(result).toEqual(mockVariablesData);
});
it('should return empty array if no variables file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteVariablesFromFile();
expect(result).toHaveLength(0);
});
});
describe('getRemoteTagsAndMappingsFromFile', () => {
it('should parse tags and mappings file correctly', async () => {
globMock.mockResolvedValue(['/mock/tags.json']);
const mockTagsData = {
tags: [{ id: 'tag1', name: 'Tag 1' }],
mappings: [{ workflowId: 'workflow1', tagId: 'tag1' }],
};
fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData));
const result = await service.getRemoteTagsAndMappingsFromFile();
expect(result.tags).toEqual(mockTagsData.tags);
expect(result.mappings).toEqual(mockTagsData.mappings);
});
it('should return empty tags and mappings if no file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteTagsAndMappingsFromFile();
expect(result.tags).toHaveLength(0);
expect(result.mappings).toHaveLength(0);
});
});
describe('getLocalVersionIdsFromDb', () => {
const now = new Date();
jest.useFakeTimers({ now });
it('should replace invalid updatedAt with current timestamp', async () => {
const mockWorkflows = [
{
id: 'workflow1',
name: 'Test Workflow',
updatedAt: 'invalid-date',
},
] as unknown as WorkflowEntity[];
workflowRepository.find.mockResolvedValue(mockWorkflows);
const result = await service.getLocalVersionIdsFromDb();
expect(result[0].updatedAt).toBe(now.toISOString());
});
});
});

View file

@ -0,0 +1,27 @@
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
import type { SourceControlPreferences } from '../types/source-control-preferences';
describe('SourceControlPreferencesService', () => {
const instanceSettings = mock<InstanceSettings>({ n8nFolder: '' });
const service = new SourceControlPreferencesService(
instanceSettings,
mock(),
mock(),
mock(),
mock(),
);
it('should class validate correct preferences', async () => {
const validPreferences: Partial<SourceControlPreferences> = {
branchName: 'main',
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
branchReadOnly: false,
branchColor: '#5296D6',
};
const validationResult = await service.validateSourceControlPreferences(validPreferences);
expect(validationResult).toBeTruthy();
});
});

View file

@ -10,6 +10,8 @@ describe('SourceControlService', () => {
Container.get(InstanceSettings),
mock(),
mock(),
mock(),
mock(),
);
const sourceControlService = new SourceControlService(
mock(),

View file

@ -1,5 +1,5 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
import { rmSync } from 'fs';
import { Credentials, InstanceSettings, Logger } from 'n8n-core';
import { ApplicationError, type ICredentialDataDecryptedObject } from 'n8n-workflow';
@ -44,6 +44,10 @@ export class SourceControlExportService {
private readonly logger: Logger,
private readonly variablesService: VariablesService,
private readonly tagRepository: TagRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
instanceSettings: InstanceSettings,
) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -106,17 +110,16 @@ export class SourceControlExportService {
try {
sourceControlFoldersExistCheck([this.workflowExportFolder]);
const workflowIds = candidates.map((e) => e.id);
const sharedWorkflows =
await Container.get(SharedWorkflowRepository).findByWorkflowIds(workflowIds);
const workflows = await Container.get(WorkflowRepository).findByIds(workflowIds);
const sharedWorkflows = await this.sharedWorkflowRepository.findByWorkflowIds(workflowIds);
const workflows = await this.workflowRepository.findByIds(workflowIds);
// determine owner of each workflow to be exported
const owners: Record<string, ResourceOwner> = {};
sharedWorkflows.forEach((e) => {
const project = e.project;
sharedWorkflows.forEach((sharedWorkflow) => {
const project = sharedWorkflow.project;
if (!project) {
throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`);
throw new ApplicationError(`Workflow ${sharedWorkflow.workflow.display()} has no owner`);
}
if (project.type === 'personal') {
@ -124,14 +127,16 @@ export class SourceControlExportService {
(pr) => pr.role === 'project:personalOwner',
);
if (!ownerRelation) {
throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`);
throw new ApplicationError(
`Workflow ${sharedWorkflow.workflow.display()} has no owner`,
);
}
owners[e.workflowId] = {
owners[sharedWorkflow.workflowId] = {
type: 'personal',
personalEmail: ownerRelation.user.email,
};
} else if (project.type === 'team') {
owners[e.workflowId] = {
owners[sharedWorkflow.workflowId] = {
type: 'team',
teamId: project.id,
teamName: project.name,
@ -156,6 +161,7 @@ export class SourceControlExportService {
})),
};
} catch (error) {
if (error instanceof ApplicationError) throw error;
throw new ApplicationError('Failed to export workflows to work folder', { cause: error });
}
}
@ -204,7 +210,7 @@ export class SourceControlExportService {
files: [],
};
}
const mappings = await Container.get(WorkflowTagMappingRepository).find();
const mappings = await this.workflowTagMappingRepository.find();
const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
await fsWriteFile(
fileName,
@ -260,9 +266,10 @@ export class SourceControlExportService {
try {
sourceControlFoldersExistCheck([this.credentialExportFolder]);
const credentialIds = candidates.map((e) => e.id);
const credentialsToBeExported = await Container.get(
SharedCredentialsRepository,
).findByCredentialIds(credentialIds, 'credential:owner');
const credentialsToBeExported = await this.sharedCredentialsRepository.findByCredentialIds(
credentialIds,
'credential:owner',
);
let missingIds: string[] = [];
if (credentialsToBeExported.length !== credentialIds.length) {
const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId);

View file

@ -1,5 +1,5 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import glob from 'fast-glob';
@ -53,7 +53,15 @@ export class SourceControlImportService {
private readonly errorReporter: ErrorReporter,
private readonly variablesService: VariablesService,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly credentialsRepository: CredentialsRepository,
private readonly projectRepository: ProjectRepository,
private readonly tagRepository: TagRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly userRepository: UserRepository,
private readonly variablesRepository: VariablesRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
instanceSettings: InstanceSettings,
) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -91,7 +99,7 @@ export class SourceControlImportService {
}
async getLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
const localWorkflows = await Container.get(WorkflowRepository).find({
const localWorkflows = await this.workflowRepository.find({
select: ['id', 'name', 'versionId', 'updatedAt'],
});
return localWorkflows.map((local) => {
@ -146,7 +154,7 @@ export class SourceControlImportService {
}
async getLocalCredentialsFromDb(): Promise<Array<ExportableCredential & { filename: string }>> {
const localCredentials = await Container.get(CredentialsRepository).find({
const localCredentials = await this.credentialsRepository.find({
select: ['id', 'name', 'type'],
});
return localCredentials.map((local) => ({
@ -201,24 +209,22 @@ export class SourceControlImportService {
const localTags = await this.tagRepository.find({
select: ['id', 'name'],
});
const localMappings = await Container.get(WorkflowTagMappingRepository).find({
const localMappings = await this.workflowTagMappingRepository.find({
select: ['workflowId', 'tagId'],
});
return { tags: localTags, mappings: localMappings };
}
async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const personalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
const workflowManager = this.activeWorkflowManager;
const candidateIds = candidates.map((c) => c.id);
const existingWorkflows = await Container.get(WorkflowRepository).findByIds(candidateIds, {
const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
fields: ['id', 'name', 'versionId', 'active'],
});
const allSharedWorkflows = await Container.get(SharedWorkflowRepository).findWithFields(
candidateIds,
{ select: ['workflowId', 'role', 'projectId'] },
);
const allSharedWorkflows = await this.sharedWorkflowRepository.findWithFields(candidateIds, {
select: ['workflowId', 'role', 'projectId'],
});
const importWorkflowsResult = [];
// Due to SQLite concurrency issues, we cannot save all workflows at once
@ -235,9 +241,7 @@ export class SourceControlImportService {
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
importedWorkflow.active = existingWorkflow?.active ?? false;
this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`);
const upsertResult = await Container.get(WorkflowRepository).upsert({ ...importedWorkflow }, [
'id',
]);
const upsertResult = await this.workflowRepository.upsert({ ...importedWorkflow }, ['id']);
if (upsertResult?.identifiers?.length !== 1) {
throw new ApplicationError('Failed to upsert workflow', {
extra: { workflowId: importedWorkflow.id ?? 'new' },
@ -253,7 +257,7 @@ export class SourceControlImportService {
? await this.findOrCreateOwnerProject(importedWorkflow.owner)
: null;
await Container.get(SharedWorkflowRepository).upsert(
await this.sharedWorkflowRepository.upsert(
{
workflowId: importedWorkflow.id,
projectId: remoteOwnerProject?.id ?? personalProject.id,
@ -276,7 +280,7 @@ export class SourceControlImportService {
const error = ensureError(e);
this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error });
} finally {
await Container.get(WorkflowRepository).update(
await this.workflowRepository.update(
{ id: existingWorkflow.id },
{ versionId: importedWorkflow.versionId },
);
@ -295,16 +299,15 @@ export class SourceControlImportService {
}
async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const personalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
const candidateIds = candidates.map((c) => c.id);
const existingCredentials = await Container.get(CredentialsRepository).find({
const existingCredentials = await this.credentialsRepository.find({
where: {
id: In(candidateIds),
},
select: ['id', 'name', 'type', 'data'],
});
const existingSharedCredentials = await Container.get(SharedCredentialsRepository).find({
const existingSharedCredentials = await this.sharedCredentialsRepository.find({
select: ['credentialsId', 'role'],
where: {
credentialsId: In(candidateIds),
@ -336,7 +339,7 @@ export class SourceControlImportService {
}
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']);
await this.credentialsRepository.upsert(newCredentialObject, ['id']);
const isOwnedLocally = existingSharedCredentials.some(
(c) => c.credentialsId === credential.id && c.role === 'credential:owner',
@ -352,7 +355,7 @@ export class SourceControlImportService {
newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id;
newSharedCredential.role = 'credential:owner';
await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [
await this.sharedCredentialsRepository.upsert({ ...newSharedCredential }, [
'credentialsId',
'projectId',
]);
@ -388,7 +391,7 @@ export class SourceControlImportService {
const existingWorkflowIds = new Set(
(
await Container.get(WorkflowRepository).find({
await this.workflowRepository.find({
select: ['id'],
})
).map((e) => e.id),
@ -417,7 +420,7 @@ export class SourceControlImportService {
await Promise.all(
mappedTags.mappings.map(async (mapping) => {
if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
await Container.get(WorkflowTagMappingRepository).upsert(
await this.workflowTagMappingRepository.upsert(
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
{
skipUpdateIfNoValuesChanged: true,
@ -464,12 +467,12 @@ export class SourceControlImportService {
overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
}
try {
await Container.get(VariablesRepository).upsert({ ...variable }, ['id']);
await this.variablesRepository.upsert({ ...variable }, ['id']);
} catch (errorUpsert) {
if (isUniqueConstraintError(errorUpsert as Error)) {
this.logger.debug(`Variable ${variable.key} already exists, updating instead`);
try {
await Container.get(VariablesRepository).update({ key: variable.key }, { ...variable });
await this.variablesRepository.update({ key: variable.key }, { ...variable });
} catch (errorUpdate) {
this.logger.debug(`Failed to update variable ${variable.key}, skipping`);
this.logger.debug((errorUpdate as Error).message);
@ -484,11 +487,11 @@ export class SourceControlImportService {
if (overriddenKeys.length > 0 && valueOverrides) {
for (const key of overriddenKeys) {
result.imported.push(key);
const newVariable = Container.get(VariablesRepository).create({
const newVariable = this.variablesRepository.create({
key,
value: valueOverrides[key],
});
await Container.get(VariablesRepository).save(newVariable, { transaction: false });
await this.variablesRepository.save(newVariable, { transaction: false });
}
}
@ -498,32 +501,30 @@ export class SourceControlImportService {
}
private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> {
const projectRepository = Container.get(ProjectRepository);
const userRepository = Container.get(UserRepository);
if (typeof owner === 'string' || owner.type === 'personal') {
const email = typeof owner === 'string' ? owner : owner.personalEmail;
const user = await userRepository.findOne({
const user = await this.userRepository.findOne({
where: { email },
});
if (!user) {
return null;
}
return await projectRepository.getPersonalProjectForUserOrFail(user.id);
return await this.projectRepository.getPersonalProjectForUserOrFail(user.id);
} else if (owner.type === 'team') {
let teamProject = await projectRepository.findOne({
let teamProject = await this.projectRepository.findOne({
where: { id: owner.teamId },
});
if (!teamProject) {
try {
teamProject = await projectRepository.save(
projectRepository.create({
teamProject = await this.projectRepository.save(
this.projectRepository.create({
id: owner.teamId,
name: owner.teamName,
type: 'team',
}),
);
} catch (e) {
teamProject = await projectRepository.findOne({
teamProject = await this.projectRepository.findOne({
where: { id: owner.teamId },
});
if (!teamProject) {

View file

@ -1,4 +1,4 @@
import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
import type { ValidationError } from 'class-validator';
import { validate } from 'class-validator';
import { rm as fsRm } from 'fs/promises';
@ -7,7 +7,6 @@ import { ApplicationError, jsonParse } from 'n8n-workflow';
import { writeFile, chmod, readFile } from 'node:fs/promises';
import path from 'path';
import config from '@/config';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import {
@ -17,6 +16,7 @@ import {
SOURCE_CONTROL_PREFERENCES_DB_KEY,
} from './constants';
import { generateSshKeyPair, isSourceControlLicensed } from './source-control-helper.ee';
import { SourceControlConfig } from './source-control.config';
import type { KeyPairType } from './types/key-pair-type';
import { SourceControlPreferences } from './types/source-control-preferences';
@ -34,6 +34,8 @@ export class SourceControlPreferencesService {
private readonly instanceSettings: InstanceSettings,
private readonly logger: Logger,
private readonly cipher: Cipher,
private readonly settingsRepository: SettingsRepository,
private readonly sourceControlConfig: SourceControlConfig,
) {
this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER);
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -64,9 +66,7 @@ export class SourceControlPreferencesService {
}
private async getKeyPairFromDatabase() {
const dbSetting = await Container.get(SettingsRepository).findByKey(
'features.sourceControl.sshKeys',
);
const dbSetting = await this.settingsRepository.findByKey('features.sourceControl.sshKeys');
if (!dbSetting?.value) return null;
@ -120,7 +120,7 @@ export class SourceControlPreferencesService {
async deleteKeyPair() {
try {
await fsRm(this.sshFolder, { recursive: true });
await Container.get(SettingsRepository).delete({ key: 'features.sourceControl.sshKeys' });
await this.settingsRepository.delete({ key: 'features.sourceControl.sshKeys' });
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
this.logger.error(`Failed to delete SSH key pair: ${error.message}`);
@ -133,14 +133,12 @@ export class SourceControlPreferencesService {
async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise<SourceControlPreferences> {
if (!keyPairType) {
keyPairType =
this.getPreferences().keyGeneratorType ??
(config.get('sourceControl.defaultKeyPairType') as KeyPairType) ??
'ed25519';
this.getPreferences().keyGeneratorType ?? this.sourceControlConfig.defaultKeyPairType;
}
const keyPair = await generateSshKeyPair(keyPairType);
try {
await Container.get(SettingsRepository).save({
await this.settingsRepository.save({
key: 'features.sourceControl.sshKeys',
value: JSON.stringify({
encryptedPrivateKey: this.cipher.encrypt(keyPair.privateKey),
@ -211,7 +209,7 @@ export class SourceControlPreferencesService {
if (saveToDb) {
const settingsValue = JSON.stringify(this._sourceControlPreferences);
try {
await Container.get(SettingsRepository).save(
await this.settingsRepository.save(
{
key: SOURCE_CONTROL_PREFERENCES_DB_KEY,
value: settingsValue,
@ -229,7 +227,7 @@ export class SourceControlPreferencesService {
async loadFromDbAndApplySourceControlPreferences(): Promise<
SourceControlPreferences | undefined
> {
const loadedPreferences = await Container.get(SettingsRepository).findOne({
const loadedPreferences = await this.settingsRepository.findOne({
where: { key: SOURCE_CONTROL_PREFERENCES_DB_KEY },
});
if (loadedPreferences) {

View file

@ -0,0 +1,8 @@
import { Config, Env } from '@n8n/config';
@Config
export class SourceControlConfig {
/** Default SSH key type to use when generating SSH keys. */
@Env('N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE')
defaultKeyPairType: 'ed25519' | 'rsa' = 'ed25519';
}

View file

@ -1,27 +0,0 @@
import { Container } from '@n8n/di';
import { License } from '@/license';
export function isVariablesEnabled(): boolean {
const license = Container.get(License);
return license.isVariablesEnabled();
}
export function canCreateNewVariable(variableCount: number): boolean {
if (!isVariablesEnabled()) {
return false;
}
const license = Container.get(License);
// This defaults to -1 which is what we want if we've enabled
// variables via the config
const limit = license.getVariablesLimit();
if (limit === -1) {
return true;
}
return limit > variableCount;
}
export function getVariablesLimit(): number {
const license = Container.get(License);
return license.getVariablesLimit();
}

View file

@ -1,4 +1,4 @@
import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
import type { Variables } from '@/databases/entities/variables';
import { VariablesRepository } from '@/databases/repositories/variables.repository';
@ -6,23 +6,21 @@ import { generateNanoId } from '@/databases/utils/generators';
import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error';
import { VariableValidationError } from '@/errors/variable-validation.error';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { CacheService } from '@/services/cache/cache.service';
import { canCreateNewVariable } from './environment-helpers';
@Service()
export class VariablesService {
constructor(
protected cacheService: CacheService,
protected variablesRepository: VariablesRepository,
private readonly cacheService: CacheService,
private readonly variablesRepository: VariablesRepository,
private readonly eventService: EventService,
private readonly license: License,
) {}
async getAllCached(state?: 'empty'): Promise<Variables[]> {
let variables = await this.cacheService.get('variables', {
async refreshFn() {
return await Container.get(VariablesService).findAll();
},
refreshFn: async () => await this.findAll(),
});
if (variables === undefined) {
@ -77,7 +75,7 @@ export class VariablesService {
}
async create(variable: Omit<Variables, 'id'>): Promise<Variables> {
if (!canCreateNewVariable(await this.getCount())) {
if (!this.canCreateNewVariable(await this.getCount())) {
throw new VariableCountLimitReachedError('Variables limit reached');
}
this.validateVariable(variable);
@ -100,4 +98,17 @@ export class VariablesService {
await this.updateCache();
return (await this.getCached(id))!;
}
private canCreateNewVariable(variableCount: number): boolean {
if (!this.license.isVariablesEnabled()) {
return false;
}
// This defaults to -1 which is what we want if we've enabled
// variables via the config
const limit = this.license.getVariablesLimit();
if (limit === -1) {
return true;
}
return limit > variableCount;
}
}

View file

@ -19,7 +19,7 @@ import type { WorkflowRepository } from '@/databases/repositories/workflow.repos
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
import type { WorkflowRunner } from '@/workflow-runner';
import { mockInstance } from '@test/mocking';
import { mockInstance, mockLogger } from '@test/mocking';
import { mockNodeTypesData } from '@test-integration/utils/node-types-data';
import { TestRunnerService } from '../test-runner.service.ee';
@ -129,6 +129,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
});
}
const errorReporter = mock<ErrorReporter>();
const logger = mockLogger();
describe('TestRunnerService', () => {
const executionRepository = mock<ExecutionRepository>();
const workflowRepository = mock<WorkflowRepository>();
@ -176,6 +179,7 @@ describe('TestRunnerService', () => {
test('should create an instance of TestRunnerService', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -183,7 +187,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -191,6 +195,7 @@ describe('TestRunnerService', () => {
test('should create and run test cases from past executions', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -198,7 +203,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -229,6 +234,7 @@ describe('TestRunnerService', () => {
test('should run both workflow under test and evaluation workflow', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -236,7 +242,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -330,6 +336,7 @@ describe('TestRunnerService', () => {
test('should properly count passed and failed executions', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -337,7 +344,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -388,6 +395,7 @@ describe('TestRunnerService', () => {
test('should properly count failed test executions', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -395,7 +403,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -442,6 +450,7 @@ describe('TestRunnerService', () => {
test('should properly count failed evaluations', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -449,7 +458,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -500,6 +509,7 @@ describe('TestRunnerService', () => {
test('should specify correct start nodes when running workflow under test', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -507,7 +517,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -574,6 +584,7 @@ describe('TestRunnerService', () => {
test('should properly choose trigger and start nodes', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -581,7 +592,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
const startNodesData = (testRunnerService as any).getStartNodesData(
@ -599,6 +610,7 @@ describe('TestRunnerService', () => {
test('should properly choose trigger and start nodes 2', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
@ -606,7 +618,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
errorReporter,
);
const startNodesData = (testRunnerService as any).getStartNodesData(

View file

@ -1,6 +1,6 @@
import { Service } from '@n8n/di';
import { parse } from 'flatted';
import { ErrorReporter } from 'n8n-core';
import { ErrorReporter, Logger } from 'n8n-core';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import type {
IDataObject,
@ -39,6 +39,7 @@ import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
@Service()
export class TestRunnerService {
constructor(
private readonly logger: Logger,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowRunner: WorkflowRunner,
private readonly executionRepository: ExecutionRepository,
@ -115,8 +116,9 @@ export class TestRunnerService {
executionMode: 'evaluation',
runData: {},
pinData,
workflowData: workflow,
workflowData: { ...workflow, pinData },
userId,
partialExecutionVersion: '1',
};
// Trigger the workflow under test with mocked data
@ -203,6 +205,8 @@ export class TestRunnerService {
* Creates a new test run for the given test definition.
*/
async runTest(user: User, test: TestDefinition): Promise<void> {
this.logger.debug('Starting new test run', { testId: test.id });
const workflow = await this.workflowRepository.findById(test.workflowId);
assert(workflow, 'Workflow not found');
@ -227,6 +231,8 @@ export class TestRunnerService {
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
.getMany();
this.logger.debug('Found past executions', { count: pastExecutions.length });
// Get the metrics to collect from the evaluation workflow
const testMetricNames = await this.getTestMetricNames(test.id);
@ -238,6 +244,8 @@ export class TestRunnerService {
const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) {
this.logger.debug('Running test case', { pastExecutionId });
try {
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
@ -257,6 +265,8 @@ export class TestRunnerService {
user.id,
);
this.logger.debug('Test case execution finished', { pastExecutionId });
// In case of a permission check issue, the test case execution will be undefined.
// Skip them, increment the failed count and continue with the next test case
if (!testCaseExecution) {
@ -279,6 +289,8 @@ export class TestRunnerService {
);
assert(evalExecution);
this.logger.debug('Evaluation execution finished', { pastExecutionId });
metrics.addResults(this.extractEvaluationResult(evalExecution));
if (evalExecution.data.resultData.error) {
@ -297,5 +309,7 @@ export class TestRunnerService {
const aggregatedMetrics = metrics.getAggregatedMetrics();
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
this.logger.debug('Test run finished', { testId: test.id });
}
}

View file

@ -71,7 +71,11 @@ export class ManualExecutionService {
},
};
const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData);
const workflowExecute = new WorkflowExecute(
additionalData,
data.executionMode,
executionData,
);
return workflowExecute.processRunExecutionData(workflow);
} else if (
data.runData === undefined ||

View file

@ -1,6 +1,6 @@
import { GlobalConfig } from '@n8n/config';
import { Container, Service } from '@n8n/di';
import { ErrorReporter, InstanceSettings, Logger } from 'n8n-core';
import { ErrorReporter, InstanceSettings, isObjectLiteral, Logger } from 'n8n-core';
import {
ApplicationError,
BINARY_ENCODING,
@ -93,6 +93,12 @@ export class ScalingService {
void this.queue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => {
try {
if (!this.hasValidJobData(job)) {
throw new ApplicationError('Worker received invalid job', {
extra: { jobData: jsonStringify(job, { replaceCircularRefs: true }) },
});
}
await this.jobProcessor.processJob(job);
} catch (error) {
await this.reportJobProcessingError(ensureError(error), job);
@ -503,5 +509,9 @@ export class ScalingService {
: jsonStringify(error, { replaceCircularRefs: true });
}
private hasValidJobData(job: Job) {
return isObjectLiteral(job.data) && 'executionId' in job.data && 'loadStaticData' in job.data;
}
// #endregion
}

View file

@ -12,7 +12,6 @@ import config from '@/config';
import { inE2ETests, LICENSE_FEATURES, N8N_VERSION } from '@/constants';
import { CredentialTypes } from '@/credential-types';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getVariablesLimit } from '@/environments.ee/variables/environment-helpers';
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -326,7 +325,7 @@ export class FrontendService {
}
if (this.license.isVariablesEnabled()) {
this.settings.variables.limit = getVariablesLimit();
this.settings.variables.limit = this.license.getVariablesLimit();
}
if (this.license.isWorkflowHistoryLicensed() && config.getEnv('workflowHistory.enabled')) {

View file

@ -454,7 +454,7 @@ export async function executeWebhook(
}
let pinData: IPinData | undefined;
const usePinData = executionMode === 'manual';
const usePinData = ['manual', 'evaluation'].includes(executionMode);
if (usePinData) {
pinData = workflowData.pinData;
runExecutionData.resultData.pinData = pinData;

View file

@ -234,7 +234,7 @@ export class WorkflowRunner {
}
let pinData: IPinData | undefined;
if (data.executionMode === 'manual') {
if (['manual', 'evaluation'].includes(data.executionMode)) {
pinData = data.pinData ?? data.workflowData.pinData;
}

View file

@ -10,6 +10,7 @@ import fsp from 'node:fs/promises';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
@ -21,20 +22,36 @@ import { randomCredentialPayload } from '../shared/random';
import * as testDb from '../shared/test-db';
describe('SourceControlImportService', () => {
let credentialsRepository: CredentialsRepository;
let projectRepository: ProjectRepository;
let sharedCredentialsRepository: SharedCredentialsRepository;
let userRepository: UserRepository;
let service: SourceControlImportService;
const cipher = mockInstance(Cipher);
beforeAll(async () => {
await testDb.init();
credentialsRepository = Container.get(CredentialsRepository);
projectRepository = Container.get(ProjectRepository);
sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
userRepository = Container.get(UserRepository);
service = new SourceControlImportService(
mock(),
mock(),
mock(),
mock(),
credentialsRepository,
projectRepository,
mock(),
mock(),
sharedCredentialsRepository,
userRepository,
mock(),
mock(),
mock(),
mock<InstanceSettings>({ n8nFolder: '/some-path' }),
);
await testDb.init();
});
afterEach(async () => {
@ -75,7 +92,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(member);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
@ -112,7 +129,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(importingUser);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
@ -149,7 +166,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(importingUser);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
@ -190,7 +207,7 @@ describe('SourceControlImportService', () => {
const personalProject = await getPersonalProject(importingUser);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: personalProject.id,
role: 'credential:owner',
@ -223,7 +240,7 @@ describe('SourceControlImportService', () => {
cipher.encrypt.mockReturnValue('some-encrypted-data');
{
const project = await Container.get(ProjectRepository).findOne({
const project = await projectRepository.findOne({
where: [
{
id: '1234-asdf',
@ -241,7 +258,7 @@ describe('SourceControlImportService', () => {
importingUser.id,
);
const sharing = await Container.get(SharedCredentialsRepository).findOne({
const sharing = await sharedCredentialsRepository.findOne({
where: {
credentialsId: CREDENTIAL_ID,
role: 'credential:owner',
@ -288,7 +305,7 @@ describe('SourceControlImportService', () => {
importingUser.id,
);
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
const sharing = await sharedCredentialsRepository.findOneBy({
credentialsId: CREDENTIAL_ID,
projectId: project.id,
role: 'credential:owner',
@ -332,7 +349,7 @@ describe('SourceControlImportService', () => {
);
await expect(
Container.get(SharedCredentialsRepository).findBy({
sharedCredentialsRepository.findBy({
credentialsId: credential.id,
}),
).resolves.toMatchObject([
@ -342,7 +359,7 @@ describe('SourceControlImportService', () => {
},
]);
await expect(
Container.get(CredentialsRepository).findBy({
credentialsRepository.findBy({
id: credential.id,
}),
).resolves.toMatchObject([

View file

@ -1,7 +1,6 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import config from '@/config';
import type { User } from '@/databases/entities/user';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
@ -21,11 +20,17 @@ const testServer = utils.setupTestServer({
enabledFeatures: ['feat:sourceControl', 'feat:sharing'],
});
let sourceControlPreferencesService: SourceControlPreferencesService;
beforeAll(async () => {
owner = await createUser({ role: 'global:owner' });
authOwnerAgent = testServer.authAgentFor(owner);
Container.get(SourceControlPreferencesService).isSourceControlConnected = () => true;
sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
await sourceControlPreferencesService.setPreferences({
connected: true,
keyGeneratorType: 'rsa',
});
});
describe('GET /sourceControl/preferences', () => {
@ -65,19 +70,11 @@ describe('GET /sourceControl/preferences', () => {
});
test('refreshing key pairsshould return new rsa key', async () => {
config.set('sourceControl.defaultKeyPairType', 'rsa');
await authOwnerAgent
.post('/source-control/generate-key-pair')
.send()
.expect(200)
.expect((res) => {
expect(
Container.get(SourceControlPreferencesService).getPreferences().keyGeneratorType,
).toBe('rsa');
expect(res.body.data).toHaveProperty('publicKey');
expect(res.body.data).toHaveProperty('keyGeneratorType');
expect(res.body.data.keyGeneratorType).toBe('rsa');
expect(res.body.data.publicKey).toContain('ssh-rsa');
});
const res = await authOwnerAgent.post('/source-control/generate-key-pair').send().expect(200);
expect(res.body.data).toHaveProperty('publicKey');
expect(res.body.data).toHaveProperty('keyGeneratorType');
expect(res.body.data.keyGeneratorType).toBe('rsa');
expect(res.body.data.publicKey).toContain('ssh-rsa');
});
});

View file

@ -80,8 +80,8 @@ export abstract class DirectoryLoader {
constructor(
readonly directory: string,
protected readonly excludeNodes: string[] = [],
protected readonly includeNodes: string[] = [],
protected excludeNodes: string[] = [],
protected includeNodes: string[] = [],
) {}
abstract packageName: string;
@ -121,13 +121,12 @@ export abstract class DirectoryLoader {
this.addCodex(tempNode, filePath);
const nodeType = tempNode.description.name;
const fullNodeType = `${this.packageName}.${nodeType}`;
if (this.includeNodes.length && !this.includeNodes.includes(fullNodeType)) {
if (this.includeNodes.length && !this.includeNodes.includes(nodeType)) {
return;
}
if (this.excludeNodes.includes(fullNodeType)) {
if (this.excludeNodes.includes(nodeType)) {
return;
}
@ -151,7 +150,7 @@ export abstract class DirectoryLoader {
if (currentVersionNode.hasOwnProperty('executeSingle')) {
throw new ApplicationError(
'"executeSingle" has been removed. Please update the code of this node to use "execute" instead.',
{ extra: { nodeType: fullNodeType } },
{ extra: { nodeType } },
);
}
} else {
@ -430,9 +429,25 @@ export class CustomDirectoryLoader extends DirectoryLoader {
* e.g. /nodes-base or community packages.
*/
export class PackageDirectoryLoader extends DirectoryLoader {
packageJson: n8n.PackageJson = this.readJSONSync('package.json');
packageJson: n8n.PackageJson;
packageName = this.packageJson.name;
packageName: string;
constructor(directory: string, excludeNodes: string[] = [], includeNodes: string[] = []) {
super(directory, excludeNodes, includeNodes);
this.packageJson = this.readJSONSync('package.json');
this.packageName = this.packageJson.name;
this.excludeNodes = this.extractNodeTypes(excludeNodes);
this.includeNodes = this.extractNodeTypes(includeNodes);
}
private extractNodeTypes(fullNodeTypes: string[]) {
return fullNodeTypes
.map((fullNodeType) => fullNodeType.split('.'))
.filter(([packageName]) => packageName === this.packageName)
.map(([_, nodeType]) => nodeType);
}
override async loadAll() {
const { n8n } = this.packageJson;
@ -524,9 +539,8 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
if (this.includeNodes.length) {
const allowedNodes: typeof this.known.nodes = {};
for (const fullNodeType of this.includeNodes) {
const [packageName, nodeType] = fullNodeType.split('.');
if (packageName === this.packageName && nodeType in this.known.nodes) {
for (const nodeType of this.includeNodes) {
if (nodeType in this.known.nodes) {
allowedNodes[nodeType] = this.known.nodes[nodeType];
}
}
@ -538,11 +552,8 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
}
if (this.excludeNodes.length) {
for (const fullNodeType of this.excludeNodes) {
const [packageName, nodeType] = fullNodeType.split('.');
if (packageName === this.packageName) {
delete this.known.nodes[nodeType];
}
for (const nodeType of this.excludeNodes) {
delete this.known.nodes[nodeType];
}
this.types.nodes = this.types.nodes.filter(

View file

@ -15,6 +15,11 @@ type ErrorReporterInitOptions = {
release: string;
environment: string;
serverName: string;
/**
* Function to allow filtering out errors before they are sent to Sentry.
* Return true if the error should be filtered out.
*/
beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
};
@Service()
@ -24,6 +29,8 @@ export class ErrorReporter {
private report: (error: Error | string, options?: ReportingOptions) => void;
private beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
constructor(private readonly logger: Logger) {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport;
@ -52,7 +59,14 @@ export class ErrorReporter {
await close(timeoutInMs);
}
async init({ dsn, serverType, release, environment, serverName }: ErrorReporterInitOptions) {
async init({
beforeSendFilter,
dsn,
serverType,
release,
environment,
serverName,
}: ErrorReporterInitOptions) {
process.on('uncaughtException', (error) => {
this.error(error);
});
@ -100,31 +114,34 @@ export class ErrorReporter {
setTag('server_type', serverType);
this.report = (error, options) => captureException(error, options);
this.beforeSendFilter = beforeSendFilter;
}
async beforeSend(event: ErrorEvent, { originalException }: EventHint) {
async beforeSend(event: ErrorEvent, hint: EventHint) {
let { originalException } = hint;
if (!originalException) return null;
if (originalException instanceof Promise) {
originalException = await originalException.catch((error) => error as Error);
}
if (originalException instanceof AxiosError) return null;
if (
originalException instanceof Error &&
originalException.name === 'QueryFailedError' &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
this.beforeSendFilter?.(event, {
...hint,
originalException,
})
) {
return null;
}
if (originalException instanceof ApplicationError) {
const { level, extra, tags } = originalException;
if (level === 'warning') return null;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
if (originalException instanceof AxiosError) return null;
if (this.isIgnoredSqliteError(originalException)) return null;
if (this.isApplicationError(originalException)) {
if (this.isIgnoredApplicationError(originalException)) return null;
this.extractEventDetailsFromApplicationError(event, originalException);
}
if (
@ -166,4 +183,31 @@ export class ErrorReporter {
if (typeof e === 'string') return new ApplicationError(e);
return;
}
/** @returns true if the error should be filtered out */
private isIgnoredSqliteError(error: unknown) {
return (
error instanceof Error &&
error.name === 'QueryFailedError' &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => error.message.includes(errMsg))
);
}
private isApplicationError(error: unknown): error is ApplicationError {
return error instanceof ApplicationError;
}
private isIgnoredApplicationError(error: ApplicationError) {
return error.level === 'warning';
}
private extractEventDetailsFromApplicationError(
event: ErrorEvent,
originalException: ApplicationError,
) {
const { level, extra, tags } = originalException;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
}
}

View file

@ -246,4 +246,67 @@ describe('validateValueAgainstSchema', () => {
// value should be type number
expect(typeof result).toEqual('number');
});
describe('when the mode is in Fixed mode, and the node is a resource mapper', () => {
const nodeType = {
description: {
properties: [
{
name: 'operation',
type: 'resourceMapper',
typeOptions: {
resourceMapper: {
mode: 'add',
},
},
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
operation: {
schema: [
{ id: 'num', type: 'number', required: true },
{ id: 'str', type: 'string', required: true },
{ id: 'obj', type: 'object', required: true },
{ id: 'arr', type: 'array', required: true },
],
attemptToConvertTypes: true,
mappingMode: '',
value: '',
},
},
} as unknown as INode;
const parameterName = 'operation.value';
describe('should correctly validate values for', () => {
test.each([
{ num: 0 },
{ num: 23 },
{ num: -0 },
{ num: -Infinity },
{ num: Infinity },
{ str: '' },
{ str: ' ' },
{ str: 'hello' },
{ arr: [] },
{ obj: {} },
])('%s', (value) => {
expect(() =>
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
).not.toThrow();
});
});
describe('should throw an error for', () => {
test.each([{ num: NaN }, { num: undefined }, { num: null }])('%s', (value) => {
expect(() =>
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
).toThrow();
});
});
});
});

View file

@ -44,7 +44,7 @@ const validateResourceMapperValue = (
!skipRequiredCheck &&
schemaEntry?.required === true &&
schemaEntry.type !== 'boolean' &&
!resolvedValue
(resolvedValue === undefined || resolvedValue === null)
) {
return {
valid: false,

View file

@ -235,10 +235,7 @@ describe('DirectoryLoader', () => {
return JSON.stringify({});
}
if (path.endsWith('types/nodes.json')) {
return JSON.stringify([
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
return JSON.stringify([{ name: 'node1' }, { name: 'node2' }]);
}
if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]);
@ -254,7 +251,7 @@ describe('DirectoryLoader', () => {
node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' },
});
expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0].name).toBe('n8n-nodes-testing.node1');
expect(loader.types.nodes[0].name).toBe('node1');
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
@ -274,10 +271,7 @@ describe('DirectoryLoader', () => {
return JSON.stringify({});
}
if (path.endsWith('types/nodes.json')) {
return JSON.stringify([
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
return JSON.stringify([{ name: 'node1' }, { name: 'node2' }]);
}
if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]);
@ -314,10 +308,7 @@ describe('DirectoryLoader', () => {
return JSON.stringify({});
}
if (path.endsWith('types/nodes.json')) {
return JSON.stringify([
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
return JSON.stringify([{ name: 'node1' }, { name: 'node2' }]);
}
if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]);
@ -333,7 +324,7 @@ describe('DirectoryLoader', () => {
node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' },
});
expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0].name).toBe('n8n-nodes-testing.node2');
expect(loader.types.nodes[0].name).toBe('node2');
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
});
@ -654,18 +645,6 @@ describe('DirectoryLoader', () => {
expect(nodeWithIcon.description.icon).toBeUndefined();
});
it('should skip node if included in excludeNodes', () => {
const loader = new CustomDirectoryLoader(directory, ['CUSTOM.node1']);
const filePath = 'dist/Node1/Node1.node.js';
loader.loadNodeFromFile(filePath);
expect(loader.nodeTypes).toEqual({});
expect(loader.known.nodes).toEqual({});
expect(loader.types.nodes).toEqual([]);
expect(loader.loadedNodes).toEqual([]);
});
it('should skip node if not in includeNodes', () => {
const loader = new CustomDirectoryLoader(directory, [], ['CUSTOM.other']);
const filePath = 'dist/Node1/Node1.node.js';

View file

@ -101,6 +101,37 @@ describe('ErrorReporter', () => {
const result = await errorReporter.beforeSend(event, { originalException });
expect(result).toBeNull();
});
describe('beforeSendFilter', () => {
const newErrorReportedWithBeforeSendFilter = (beforeSendFilter: jest.Mock) => {
const errorReporter = new ErrorReporter(mock());
// @ts-expect-error - beforeSendFilter is private
errorReporter.beforeSendFilter = beforeSendFilter;
return errorReporter;
};
it('should filter out based on the beforeSendFilter', async () => {
const beforeSendFilter = jest.fn().mockReturnValue(true);
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
const hint = { originalException: new Error() };
const result = await errorReporter.beforeSend(event, hint);
expect(result).toBeNull();
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
});
it('should not filter out when beforeSendFilter returns false', async () => {
const beforeSendFilter = jest.fn().mockReturnValue(false);
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
const hint = { originalException: new Error() };
const result = await errorReporter.beforeSend(event, hint);
expect(result).toEqual(event);
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
});
});
});
describe('error', () => {

View file

@ -136,6 +136,12 @@ defineExpose({
<template v-if="$slots.suffix" #suffix>
<slot name="suffix" />
</template>
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
<template v-if="$slots.empty" #empty>
<slot name="empty" />
</template>
<slot></slot>
</ElSelect>
</div>

View file

@ -37,6 +37,8 @@
// Danger
--color-danger-shade-1: var(--prim-color-alt-c-shade-100);
--color-danger: var(--prim-color-alt-c);
--color-danger-light: var(--prim-color-alt-c-tint-150);
--color-danger-light-2: var(--prim-color-alt-c-tint-250);
--color-danger-tint-1: var(--prim-color-alt-c-tint-400);
--color-danger-tint-2: var(--prim-color-alt-c-tint-450);

View file

@ -32,6 +32,7 @@ export async function getAllCredentials(
): Promise<ICredentialsResponse[]> {
return await makeRestApiRequest(context, 'GET', '/credentials', {
...(includeScopes ? { includeScopes } : {}),
includeData: true,
...(filter ? { filter } : {}),
});
}

View file

@ -33,7 +33,7 @@ export const pushWorkfolder = async (
export const pullWorkfolder = async (
context: IRestApiContext,
data: PullWorkFolderRequestDto,
): Promise<void> => {
): Promise<SourceControlledFile[]> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
};

View file

@ -29,6 +29,7 @@ const props = withDefaults(
defineProps<{
data: ICredentialsResponse;
readOnly?: boolean;
needsSetup?: boolean;
}>(),
{
data: () => ({
@ -146,6 +147,9 @@ function moveResource() {
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
{{ locale.baseText('credentials.item.readonly') }}
</N8nBadge>
<N8nBadge v-if="needsSetup" class="ml-3xs" theme="warning">
{{ locale.baseText('credentials.item.needsSetup') }}
</N8nBadge>
</n8n-heading>
</template>
<div :class="$style.cardDescription">
@ -195,10 +199,6 @@ function moveResource() {
.cardHeading {
font-size: var(--font-size-s);
padding: var(--spacing-s) 0 0;
span {
color: var(--color-text-light);
}
}
.cardDescription {

View file

@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es';
import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, STORES } from '@/constants';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store';
@ -18,8 +18,9 @@ let rbacStore: ReturnType<typeof useRBACStore>;
const showMessage = vi.fn();
const showError = vi.fn();
const showToast = vi.fn();
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ showMessage, showError }),
useToast: () => ({ showMessage, showError, showToast }),
}));
const renderComponent = createComponentRenderer(MainSidebarSourceControl);
@ -131,5 +132,129 @@ describe('MainSidebarSourceControl', () => {
),
);
});
it('should open push modal when there are changes', async () => {
const status = [
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow' as const,
status: 'created' as const,
location: 'local' as const,
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
];
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce(status);
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[1]);
await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: expect.objectContaining({
status,
}),
}),
),
);
});
it("should show user's feedback when pulling", async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
{
id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
name: 'Gmail account',
type: 'credential',
status: 'created',
location: 'remote',
conflict: false,
file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
updatedAt: '2025-01-09T13:12:24.586Z',
},
{
id: 'variables',
name: 'variables',
type: 'variables',
status: 'modified',
location: 'remote',
conflict: false,
file: '/variable_stubs.json',
updatedAt: '2025-01-09T13:12:24.588Z',
},
{
id: 'mappings',
name: 'tags',
type: 'tags',
status: 'modified',
location: 'remote',
conflict: false,
file: '/tags.json',
updatedAt: '2024-12-16T12:53:12.155Z',
},
]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => {
expect(showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
title: 'Finish setting up your new variables to use in workflows',
}),
);
expect(showToast).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: 'Finish setting up your new credentials to use in workflows',
}),
);
expect(showToast).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
message: '1 Workflow, 1 Credential, Variables, and Tags were pulled',
}),
);
});
});
it('should show feedback where there are no change to pull', async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => {
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Up to date',
}),
);
});
});
});
});

View file

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue';
import { computed, h, nextTick, ref } from 'vue';
import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n';
import { hasPermission } from '@/utils/rbac/permissions';
@ -9,6 +9,9 @@ import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import { sourceControlEventBus } from '@/event-bus/source-control';
import { groupBy } from 'lodash-es';
import { RouterLink } from 'vue-router';
import { VIEWS } from '@/constants';
import type { SourceControlledFile } from '@n8n/api-types';
defineProps<{
@ -64,48 +67,106 @@ async function pushWorkfolder() {
}
}
const variablesToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.variables.title'),
message: h(RouterLink, { to: { name: VIEWS.VARIABLES } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.variables.description'),
),
type: 'info' as const,
closeOnClick: true,
duration: 0,
};
const credentialsToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.credentials.title'),
message: h(RouterLink, { to: { name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.credentials.description'),
),
type: 'info' as const,
closeOnClick: true,
duration: 0,
};
const pullMessage = ({
credential,
tags,
variables,
workflow,
}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
const messages: string[] = [];
if (workflow?.length) {
messages.push(
i18n.baseText('generic.workflow', {
adjustToNumber: workflow.length,
interpolate: { count: workflow.length },
}),
);
}
if (credential?.length) {
messages.push(
i18n.baseText('generic.credential', {
adjustToNumber: credential.length,
interpolate: { count: credential.length },
}),
);
}
if (variables?.length) {
messages.push(i18n.baseText('generic.variable_plural'));
}
if (tags?.length) {
messages.push(i18n.baseText('generic.tag_plural'));
}
return [
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
'were pulled',
].join(' ');
};
async function pullWorkfolder() {
loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try {
const status: SourceControlledFile[] =
((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
const status = await sourceControlStore.pullWorkfolder(false);
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
});
if (statusWithoutLocallyCreatedWorkflows.length === 0) {
if (!status.length) {
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
type: 'success',
});
} else {
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success',
});
const incompleteFileTypes = ['variables', 'credential'];
const hasVariablesOrCredentials = (status || []).some((file) => {
return incompleteFileTypes.includes(file.type);
});
if (hasVariablesOrCredentials) {
void nextTick(() => {
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
type: 'info',
duration: 0,
showClose: true,
offset: 0,
});
});
}
return;
}
const { credential, tags, variables, workflow } = groupBy(status, 'type');
const toastMessages = [
...(variables?.length ? [variablesToast] : []),
...(credential?.length ? [credentialsToast] : []),
{
title: i18n.baseText('settings.sourceControl.pull.success.title'),
message: pullMessage({ credential, tags, variables, workflow }),
type: 'success' as const,
},
];
for (const message of toastMessages) {
/**
* the toasts stack in a reversed way, resulting in
* Success
* Credentials
* Variables
*/
//
toast.showToast(message);
await nextTick();
}
sourceControlEventBus.emit('pull');
} catch (error) {
const errorResponse = error.response;

View file

@ -141,9 +141,10 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS);
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
const websiteCategoryURL = templatesStore.websiteTemplateRepositoryParameters;
websiteCategoryURL.append('utm_user_role', 'AdvancedAI');
const websiteCategoryURLParams = templatesStore.websiteTemplateRepositoryParameters;
websiteCategoryURLParams.append('utm_user_role', 'AdvancedAI');
const websiteCategoryURL =
templatesStore.constructTemplateRepositoryURL(websiteCategoryURLParams);
return {
value: AI_NODE_CREATOR_VIEW,
@ -158,7 +159,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
icon: 'box-open',
description: i18n.baseText('nodeCreator.aiPanel.linkItem.description'),
name: 'ai_templates_root',
url: websiteCategoryURL.toString(),
url: websiteCategoryURL,
tag: {
type: 'info',
text: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerTag'),

View file

@ -1,5 +1,6 @@
import { describe, it } from 'vitest';
import { fireEvent, screen } from '@testing-library/vue';
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import NodeCredentials from './NodeCredentials.vue';
import type { RenderOptions } from '@/__tests__/render';
@ -8,6 +9,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { mockedStore } from '@/__tests__/utils';
import type { INodeUi } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useUIStore } from '../stores/ui.store';
const httpNode: INodeUi = {
parameters: {
@ -67,6 +69,7 @@ describe('NodeCredentials', () => {
const credentialsStore = mockedStore(useCredentialsStore);
const ndvStore = mockedStore(useNDVStore);
const uiStore = mockedStore(useUIStore);
beforeAll(() => {
credentialsStore.state.credentialTypes = {
@ -120,7 +123,7 @@ describe('NodeCredentials', () => {
const credentialsSelect = screen.getByTestId('node-credentials-select');
await fireEvent.click(credentialsSelect);
await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
});
@ -150,7 +153,7 @@ describe('NodeCredentials', () => {
const credentialsSelect = screen.getByTestId('node-credentials-select');
await fireEvent.click(credentialsSelect);
await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
expect(screen.queryByText('OpenAi account 2')).not.toBeInTheDocument();
@ -188,9 +191,69 @@ describe('NodeCredentials', () => {
const credentialsSelect = screen.getByTestId('node-credentials-select');
await fireEvent.click(credentialsSelect);
await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
expect(screen.queryByText('OpenAi account 2')).toBeInTheDocument();
});
it('should filter available credentials in the dropdown', async () => {
ndvStore.activeNode = httpNode;
credentialsStore.state.credentials = {
c8vqdPpPClh4TgIO: {
id: 'c8vqdPpPClh4TgIO',
name: 'OpenAi account',
type: 'openAiApi',
isManaged: false,
createdAt: '',
updatedAt: '',
},
test: {
id: 'test',
name: 'Test OpenAi account',
type: 'openAiApi',
isManaged: false,
createdAt: '',
updatedAt: '',
},
};
renderComponent();
const credentialsSelect = screen.getByTestId('node-credentials-select');
await userEvent.click(credentialsSelect);
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
expect(screen.queryByText('Test OpenAi account')).toBeInTheDocument();
const credentialSearch = credentialsSelect.querySelector('input') as HTMLElement;
await userEvent.type(credentialSearch, 'test');
expect(screen.queryByText('OpenAi account')).not.toBeInTheDocument();
expect(screen.queryByText('Test OpenAi account')).toBeInTheDocument();
});
it('should open the new credential modal when clicked', async () => {
ndvStore.activeNode = httpNode;
credentialsStore.state.credentials = {
c8vqdPpPClh4TgIO: {
id: 'c8vqdPpPClh4TgIO',
name: 'OpenAi account',
type: 'openAiApi',
isManaged: false,
createdAt: '',
updatedAt: '',
},
};
renderComponent();
const credentialsSelect = screen.getByTestId('node-credentials-select');
await userEvent.click(credentialsSelect);
await userEvent.click(screen.getByTestId('node-credentials-select-item-new'));
expect(uiStore.openNewCredential).toHaveBeenCalledWith('openAiApi', true);
});
});

View file

@ -2,11 +2,12 @@
import type { ICredentialsResponse, INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import {
HTTP_REQUEST_NODE_TYPE,
type ICredentialType,
type INodeCredentialDescription,
type INodeCredentialsDetails,
type NodeParameterValueType,
} from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useToast } from '@/composables/useToast';
@ -31,6 +32,7 @@ import {
updateNodeAuthType,
} from '@/utils/nodeTypesUtils';
import {
N8nIcon,
N8nInput,
N8nInputLabel,
N8nOption,
@ -67,7 +69,7 @@ const emit = defineEmits<{
const telemetry = useTelemetry();
const i18n = useI18n();
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`;
const NEW_CREDENTIALS_TEXT = i18n.baseText('nodeCredentials.createNew');
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
@ -79,7 +81,9 @@ const nodeHelpers = useNodeHelpers();
const toast = useToast();
const subscribedToCredentialType = ref('');
const filter = ref('');
const listeningForAuthChange = ref(false);
const selectRefs = ref<Array<InstanceType<typeof N8nSelect>>>([]);
const credentialTypesNode = computed(() =>
credentialTypesNodeDescription.value.map(
@ -344,9 +348,8 @@ function onCredentialSelected(
credentialId: string | null | undefined,
showAuthOptions = false,
) {
const newCredentialOptionSelected = credentialId === NEW_CREDENTIALS_TEXT;
if (!credentialId || newCredentialOptionSelected) {
createNewCredential(credentialType, newCredentialOptionSelected, showAuthOptions);
if (!credentialId) {
createNewCredential(credentialType, false, showAuthOptions);
return;
}
@ -501,6 +504,20 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
}
return i18n.baseText('nodeCredentials.credentialsLabel');
}
function setFilter(newFilter = '') {
filter.value = newFilter;
}
function matches(needle: string, haystack: string) {
return haystack.toLocaleLowerCase().includes(needle);
}
async function onClickCreateCredential(type: ICredentialType | INodeCredentialDescription) {
selectRefs.value.forEach((select) => select.blur());
await nextTick();
createNewCredential(type.name, true, showMixedCredentials(type));
}
</script>
<template>
@ -530,16 +547,20 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
data-test-id="node-credentials-select"
>
<N8nSelect
ref="selectRefs"
:model-value="getSelectedId(type.name)"
:placeholder="getSelectPlaceholder(type.name, getIssues(type.name))"
size="small"
filterable
:filter-method="setFilter"
:popper-class="$style.selectPopper"
@update:model-value="
(value: string) => onCredentialSelected(type.name, value, showMixedCredentials(type))
"
@blur="emit('blur', 'credentials')"
>
<N8nOption
v-for="item in options"
v-for="item in options.filter((o) => matches(filter, o.name))"
:key="item.id"
:data-test-id="`node-credentials-select-item-${item.id}`"
:label="item.name"
@ -550,13 +571,17 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
<N8nText size="small">{{ item.typeDisplayName }}</N8nText>
</div>
</N8nOption>
<N8nOption
:key="NEW_CREDENTIALS_TEXT"
data-test-id="node-credentials-select-item-new"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</N8nOption>
<template #empty> </template>
<template #footer>
<div
data-test-id="node-credentials-select-item-new"
:class="['clickable', $style.newCredential]"
@click="onClickCreateCredential(type)"
>
<N8nIcon size="xsmall" icon="plus" />
<N8nText bold>{{ NEW_CREDENTIALS_TEXT }}</N8nText>
</div>
</template>
</N8nSelect>
<div v-if="getIssues(type.name).length && !hideIssues" :class="$style.warning">
@ -567,7 +592,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
:items="getIssues(type.name)"
/>
</template>
<font-awesome-icon icon="exclamation-triangle" />
<N8nIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
@ -576,7 +601,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
:class="$style.edit"
data-test-id="credential-edit-button"
>
<font-awesome-icon
<N8nIcon
icon="pen"
class="clickable"
:title="i18n.baseText('nodeCredentials.updateCredential')"
@ -598,10 +623,25 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
}
}
.selectPopper {
:global(.el-select-dropdown__list) {
padding: 0;
}
:has(.newCredential:hover) :global(.hover) {
background-color: transparent;
}
&:not(:has(li)) .newCredential {
border-top: none;
box-shadow: none;
border-radius: var(--border-radius-base);
}
}
.warning {
min-width: 20px;
margin-left: 5px;
color: #ff8080;
margin-left: var(--spacing-4xs);
color: var(--color-danger-light);
font-size: var(--font-size-s);
}
@ -610,8 +650,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
justify-content: center;
align-items: center;
color: var(--color-text-base);
min-width: 20px;
margin-left: 5px;
margin-left: var(--spacing-3xs);
font-size: var(--font-size-s);
}
@ -629,4 +668,21 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s
display: flex;
flex-direction: column;
}
.newCredential {
display: flex;
gap: var(--spacing-3xs);
align-items: center;
font-weight: var(--font-weight-bold);
padding: var(--spacing-xs) var(--spacing-m);
background-color: var(--color-background-light);
border-top: var(--border-base);
box-shadow: var(--box-shadow-light);
clip-path: inset(-12px 0 0 0); // Only show box shadow on top
&:hover {
color: var(--color-primary);
}
}
</style>

View file

@ -4,7 +4,6 @@ import {
NodeConnectionType,
type IRunData,
type IRunExecutionData,
type NodeError,
type Workflow,
} from 'n8n-workflow';
import RunData from './RunData.vue';
@ -120,14 +119,17 @@ const hasAiMetadata = computed(() => {
return false;
});
const hasError = computed(() =>
Boolean(
workflowRunData.value &&
node.value &&
workflowRunData.value[node.value.name]?.[props.runIndex]?.error,
),
);
// Determine the initial output mode to logs if the node has an error and the logs are available
const defaultOutputMode = computed<OutputType>(() => {
const hasError =
workflowRunData.value &&
node.value &&
(workflowRunData.value[node.value.name]?.[props.runIndex]?.error as NodeError);
return Boolean(hasError) && hasAiMetadata.value ? OUTPUT_TYPE.LOGS : OUTPUT_TYPE.REGULAR;
return hasError.value && hasAiMetadata.value ? OUTPUT_TYPE.LOGS : OUTPUT_TYPE.REGULAR;
});
const isNodeRunning = computed(() => {
@ -216,7 +218,7 @@ const canPinData = computed(() => {
});
const allToolsWereUnusedNotice = computed(() => {
if (!node.value || runsCount.value === 0) return undefined;
if (!node.value || runsCount.value === 0 || hasError.value) return undefined;
// With pinned data there's no clear correct answer for whether
// we should use historic or current parents, so we don't show the notice,

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import TitledList from '@/components/TitledList.vue';
import { useI18n } from '@/composables/useI18n';
import { N8nTooltip, N8nIcon } from 'n8n-design-system';
defineProps<{
issues: string[];
@ -11,22 +12,21 @@ const i18n = useI18n();
<template>
<div v-if="issues.length" :class="$style['parameter-issues']" data-test-id="parameter-issues">
<n8n-tooltip placement="top">
<N8nTooltip placement="top">
<template #content>
<TitledList :title="`${i18n.baseText('parameterInput.issues')}:`" :items="issues" />
</template>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
<N8nIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
</template>
<style module lang="scss">
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
color: var(--color-danger-light);
font-size: var(--font-size-s);
padding-left: var(--spacing-4xs);
padding-left: var(--spacing-3xs);
}
</style>

View file

@ -6,7 +6,13 @@ import type {
CanvasEventBusEvents,
ConnectStartEvent,
} from '@/types';
import type { Connection, XYPosition, NodeDragEvent, GraphNode } from '@vue-flow/core';
import type {
Connection,
XYPosition,
NodeDragEvent,
NodeMouseEvent,
GraphNode,
} from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
@ -272,6 +278,14 @@ function onNodeDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
function onNodeClick({ event, node }: NodeMouseEvent) {
if (event.ctrlKey || event.metaKey || selectedNodes.value.length < 2) {
return;
}
onSelectNodes({ ids: [node.id] });
}
function onSelectionDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
@ -676,6 +690,7 @@ provide(CanvasKey, {
@move-start="onPaneMoveStart"
@move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop"
@node-click="onNodeClick"
@selection-drag-stop="onSelectionDragStop"
@dragover="onDragOver"
@drop="onDrop"

View file

@ -44,7 +44,18 @@ const filtersLength = computed(() => {
}
const value = props.modelValue[key];
length += (Array.isArray(value) ? value.length > 0 : value !== '') ? 1 : 0;
if (value === true) {
length += 1;
}
if (Array.isArray(value) && value.length) {
length += 1;
}
if (typeof value === 'string' && value !== '') {
length += 1;
}
});
return length;

View file

@ -168,13 +168,19 @@ const focusSearchInput = () => {
};
const hasAppliedFilters = (): boolean => {
return !!filterKeys.value.find(
(key) =>
key !== 'search' &&
(Array.isArray(props.filters[key])
? props.filters[key].length > 0
: props.filters[key] !== ''),
);
return !!filterKeys.value.find((key) => {
if (key === 'search') return false;
if (typeof props.filters[key] === 'boolean') {
return props.filters[key];
}
if (Array.isArray(props.filters[key])) {
return props.filters[key].length > 0;
}
return props.filters[key] !== '';
});
};
const setRowsPerPage = (numberOfRowsPerPage: number) => {

View file

@ -995,6 +995,18 @@ describe('useCanvasOperations', () => {
expect(ndvStore.activeNodeName).toBe('Existing Node');
});
it('should set node as dirty when node is set active', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode();
workflowsStore.getNodeById.mockImplementation(() => node);
const { setNodeActive } = useCanvasOperations({ router });
setNodeActive(node.id);
expect(workflowsStore.setNodePristine).toHaveBeenCalledWith(node.name, false);
});
});
describe('setNodeActiveByName', () => {

View file

@ -381,6 +381,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
return;
}
workflowsStore.setNodePristine(node.name, false);
setNodeActiveByName(node.name);
}
@ -1923,7 +1924,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
workflowsStore.setWorkflowExecutionData(data);
if (data.mode !== 'manual') {
if (!['manual', 'evaluation'].includes(data.mode)) {
workflowsStore.setWorkflowPinData({});
}

View file

@ -597,7 +597,6 @@
"credentialsList.confirmMessage.confirmButtonText": "Yes, delete",
"credentialsList.confirmMessage.headline": "Delete Credential?",
"credentialsList.confirmMessage.message": "Are you sure you want to delete {credentialName}?",
"credentialsList.createNewCredential": "Create New Credential",
"credentialsList.created": "Created",
"credentialsList.credentials": "Credentials",
"credentialsList.deleteCredential": "Delete Credential",
@ -625,8 +624,11 @@
"credentials.item.created": "Created",
"credentials.item.owner": "Owner",
"credentials.item.readonly": "Read only",
"credentials.item.needsSetup": "Needs first setup",
"credentials.search.placeholder": "Search credentials...",
"credentials.filters.type": "Type",
"credentials.filters.setup": "Needs first setup",
"credentials.filters.status": "Status",
"credentials.filters.active": "Some credentials may be hidden since filters are applied.",
"credentials.filters.active.reset": "Remove filters",
"credentials.sort.lastUpdated": "Sort by last updated",
@ -1187,7 +1189,7 @@
"nodeCreator.aiPanel.workflowTriggerDescription": "Runs the flow when called by the Execute Workflow node from a different workflow",
"nodeCreator.nodeItem.triggerIconTitle": "Trigger Node",
"nodeCreator.nodeItem.aiIconTitle": "LangChain AI Node",
"nodeCredentials.createNew": "Create New Credential",
"nodeCredentials.createNew": "Create new credential",
"nodeCredentials.credentialFor": "Credential for {credentialType}",
"nodeCredentials.credentialsLabel": "Credential to connect with",
"nodeCredentials.issues": "Issues",
@ -1968,6 +1970,10 @@
"settings.sourceControl.pull.success.title": "Pulled successfully",
"settings.sourceControl.pull.upToDate.title": "Up to date",
"settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git",
"settings.sourceControl.pull.upToDate.variables.title": "Finish setting up your new variables to use in workflows",
"settings.sourceControl.pull.upToDate.variables.description": "Review Variables",
"settings.sourceControl.pull.upToDate.credentials.title": "Finish setting up your new credentials to use in workflows",
"settings.sourceControl.pull.upToDate.credentials.description": "Review Credentials",
"settings.sourceControl.modals.pull.title": "Pull changes",
"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.",
"settings.sourceControl.modals.pull.description.learnMore": "More info",

View file

@ -167,6 +167,10 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
`${TEMPLATES_URLS.BASE_WEBSITE_URL}?${websiteTemplateRepositoryParameters.value.toString()}`,
);
const constructTemplateRepositoryURL = (params: URLSearchParams): string => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${params.toString()}`;
};
const addCategories = (_categories: ITemplatesCategory[]): void => {
categories.value = _categories;
};
@ -427,6 +431,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
isSearchFinished,
hasCustomTemplatesHost,
websiteTemplateRepositoryURL,
constructTemplateRepositoryURL,
websiteTemplateRepositoryParameters,
addCategories,
addCollections,

View file

@ -5,28 +5,27 @@ import CredentialsView from '@/views/CredentialsView.vue';
import { useUIStore } from '@/stores/ui.store';
import { mockedStore } from '@/__tests__/utils';
import { waitFor, within, fireEvent } from '@testing-library/vue';
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants';
import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types';
import { useRouter } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import { flushPromises } from '@vue/test-utils';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
vi.mock('@/composables/useGlobalEntityCreation', () => ({
useGlobalEntityCreation: () => ({
menu: [],
}),
}));
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
const push = vi.fn();
const replace = vi.fn();
return {
...actual,
// your mocked methods
useRouter: () => ({
push,
replace,
}),
};
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/:credentialId?',
name: VIEWS.CREDENTIALS,
component: { template: '<div></div>' },
},
],
});
const initialState = {
@ -36,14 +35,14 @@ const initialState = {
};
const renderComponent = createComponentRenderer(CredentialsView, {
global: { stubs: { ProjectHeader: true } },
global: { stubs: { ProjectHeader: true }, plugins: [router] },
});
let router: ReturnType<typeof useRouter>;
describe('CredentialsView', () => {
beforeEach(() => {
beforeEach(async () => {
createTestingPinia({ initialState });
router = useRouter();
await router.push('/');
await router.isReady();
});
afterEach(() => {
@ -115,6 +114,7 @@ describe('CredentialsView', () => {
});
it('should update credentialId route param when opened', async () => {
const replaceSpy = vi.spyOn(router, 'replace');
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isProjectHome = false;
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
@ -137,8 +137,147 @@ describe('CredentialsView', () => {
*/
await fireEvent.click(getByTestId('resources-list-item'));
await waitFor(() =>
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: '1' } }),
expect(replaceSpy).toHaveBeenCalledWith(
expect.objectContaining({ params: { credentialId: '1' } }),
),
);
});
});
describe('filters', () => {
it('should filter by type', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { type: ['test'] } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentialTypes = [
{
name: 'test',
displayName: 'test',
properties: [],
},
];
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
},
{
id: '1',
name: 'test',
type: 'another',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
},
];
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('resources-list-item').length).toBe(1);
});
it('should filter by setupNeeded', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: {} as unknown as string,
},
{
id: '1',
name: 'test',
type: 'another',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: 'any' } as unknown as string,
},
];
const { getAllByTestId, getByTestId } = renderComponent();
await flushPromises();
expect(getAllByTestId('resources-list-item').length).toBe(1);
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
it('should filter by setupNeeded when object keys are empty', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'credential needs setup',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: '' } as unknown as string,
},
{
id: '2',
name: 'random',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: 'any value' } as unknown as string,
},
];
const { getAllByTestId, getByTestId } = renderComponent();
await flushPromises();
expect(getAllByTestId('resources-list-item').length).toBe(1);
expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
it('should filter by setupNeeded when object keys are "CREDENTIAL_EMPTY_VALUE"', async () => {
await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'credential needs setup',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: CREDENTIAL_EMPTY_VALUE } as unknown as string,
},
{
id: '2',
name: 'random',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
data: { anyKey: 'any value' } as unknown as string,
},
];
const { getAllByTestId, getByTestId } = renderComponent();
await flushPromises();
expect(getAllByTestId('resources-list-item').length).toBe(1);
expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
await fireEvent.click(getByTestId('credential-filter-setup-needed'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
});
});

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
import ResourcesListLayout, {
type IResource,
type IFilters,
} from '@/components/layouts/ResourcesListLayout.vue';
import CredentialCard from '@/components/CredentialCard.vue';
import type { ICredentialType } from 'n8n-workflow';
import {
CREDENTIAL_SELECT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
@ -27,6 +27,9 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { N8nCheckbox } from 'n8n-design-system';
import { pickBy } from 'lodash-es';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
const props = defineProps<{
credentialId?: string;
@ -46,14 +49,26 @@ const router = useRouter();
const telemetry = useTelemetry();
const i18n = useI18n();
const filters = ref<IFilters>({
search: '',
homeProject: '',
type: [],
});
type Filters = IFilters & { type?: string[]; setupNeeded?: boolean };
const updateFilter = (state: Filters) => {
void router.replace({ query: pickBy(state) as LocationQueryRaw });
};
const filters = computed<Filters>(
() =>
({ ...route.query, setupNeeded: route.query.setupNeeded?.toString() === 'true' }) as Filters,
);
const loading = ref(false);
const needsSetup = (data: string | undefined): boolean => {
const dataObject = data as unknown as ICredentialsDecrypted['data'];
if (!dataObject) return false;
if (Object.keys(dataObject).length === 0) return true;
return Object.values(dataObject).every((value) => !value || value === CREDENTIAL_EMPTY_VALUE);
};
const allCredentials = computed<IResource[]>(() =>
credentialsStore.allCredentials.map((credential) => ({
id: credential.id,
@ -66,6 +81,7 @@ const allCredentials = computed<IResource[]>(() =>
type: credential.type,
sharedWithProjects: credential.sharedWithProjects,
readOnly: !getResourcePermissions(credential.scopes).credential.update,
needsSetup: needsSetup(credential.data),
})),
);
@ -84,7 +100,7 @@ const projectPermissions = computed(() =>
);
const setRouteCredentialId = (credentialId?: string) => {
void router.replace({ params: { credentialId } });
void router.replace({ params: { credentialId }, query: route.query });
};
const addCredential = () => {
@ -98,7 +114,7 @@ listenForModalChanges({
store: uiStore,
onModalClosed(modalName) {
if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
void router.replace({ params: { credentialId: '' } });
void router.replace({ params: { credentialId: '' }, query: route.query });
}
},
});
@ -121,9 +137,9 @@ watch(
);
const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
const iResource = resource as ICredentialsResponse;
const filtersToApply = newFilters as IFilters & { type: string[] };
if (filtersToApply.type.length > 0) {
const iResource = resource as ICredentialsResponse & { needsSetup: boolean };
const filtersToApply = newFilters as Filters;
if (filtersToApply.type && filtersToApply.type.length > 0) {
matches = matches && filtersToApply.type.includes(iResource.type);
}
@ -136,6 +152,10 @@ const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean):
credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString));
}
if (filtersToApply.setupNeeded) {
matches = matches && iResource.needsSetup;
}
return matches;
};
@ -156,6 +176,14 @@ const initialize = async () => {
loading.value = false;
};
credentialsStore.$onAction(({ name, after }) => {
if (name === 'createNewCredential') {
after(() => {
void credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined);
});
}
});
sourceControlStore.$onAction(({ name, after }) => {
if (name !== 'pullWorkfolder') return;
after(() => {
@ -181,7 +209,7 @@ onMounted(() => {
:type-props="{ itemSize: 77 }"
:loading="loading"
:disabled="readOnlyEnv || !projectPermissions.credential.create"
@update:filters="filters = $event"
@update:filters="updateFilter"
>
<template #header>
<ProjectHeader />
@ -192,6 +220,7 @@ onMounted(() => {
class="mb-2xs"
:data="data"
:read-only="data.readOnly"
:needs-setup="data.needsSetup"
@click="setRouteCredentialId"
/>
</template>
@ -221,6 +250,23 @@ onMounted(() => {
/>
</N8nSelect>
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('credentials.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<N8nCheckbox
:label="i18n.baseText('credentials.filters.setup')"
data-test-id="credential-filter-setup-needed"
:model-value="filters.setupNeeded"
@update:model-value="setKeyValue('setupNeeded', $event)"
>
</N8nCheckbox>
</div>
</template>
<template #empty>
<n8n-action-box

View file

@ -996,7 +996,11 @@ async function onRevertAddNode({ node }: { node: INodeUi }) {
}
async function onSwitchActiveNode(nodeName: string) {
const node = workflowsStore.getNodeByName(nodeName);
if (!node) return;
setNodeActiveByName(nodeName);
selectNodes([node.id]);
}
async function onOpenSelectiveNodeCreator(node: string, connectionType: NodeConnectionType) {
@ -1385,7 +1389,8 @@ async function onPostMessageReceived(messageEvent: MessageEvent) {
try {
// If this NodeView is used in preview mode (in iframe) it will not have access to the main app store
// so everything it needs has to be sent using post messages and passed down to child components
isProductionExecutionPreview.value = json.executionMode !== 'manual';
isProductionExecutionPreview.value =
json.executionMode !== 'manual' && json.executionMode !== 'evaluation';
await onOpenExecution(json.executionId);
canOpenNDV.value = json.canOpenNDV ?? true;

View file

@ -84,6 +84,7 @@ export const calendarFields: INodeProperties[] = [
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
@ -98,11 +99,44 @@ export const calendarFields: INodeProperties[] = [
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'End of the interval',
},
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'Options',
name: 'options',

View file

@ -112,6 +112,7 @@ export const eventFields: INodeProperties[] = [
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
@ -126,11 +127,44 @@ export const eventFields: INodeProperties[] = [
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'End time of the event',
},
{
displayName: 'Start',
name: 'start',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start time of the event, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End',
name: 'end',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End time of the event, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'Use Default Reminders',
name: 'useDefaultReminders',
@ -553,6 +587,19 @@ export const eventFields: INodeProperties[] = [
description:
'The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned.',
},
{
displayName: 'Return Next Instance of Recurring Event',
name: 'returnNextInstance',
type: 'boolean',
default: false,
description:
'Whether to return the next instance of a recurring event instead of the event itself',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Timezone',
name: 'timeZone',
@ -629,6 +676,36 @@ export const eventFields: INodeProperties[] = [
default: 50,
description: 'Max number of results to return',
},
{
displayName: 'After',
name: 'timeMin',
type: 'dateTime',
default: '={{ $now }}',
description:
'At least some part of the event must be after this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
operation: ['getAll'],
resource: ['event'],
},
},
},
{
displayName: 'Before',
name: 'timeMax',
type: 'dateTime',
default: '={{ $now.plus({ week: 1 }) }}',
description:
'At least some part of the event must be before this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
operation: ['getAll'],
resource: ['event'],
},
},
},
{
displayName: 'Options',
name: 'options',
@ -647,14 +724,39 @@ export const eventFields: INodeProperties[] = [
name: 'timeMin',
type: 'dateTime',
default: '',
description: 'At least some part of the event must be after this time',
description:
'At least some part of the event must be after this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Before',
name: 'timeMax',
type: 'dateTime',
default: '',
description: 'At least some part of the event must be before this time',
description:
'At least some part of the event must be before this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Expand Events',
name: 'singleEvents',
type: 'boolean',
default: false,
description:
'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Fields',
@ -708,6 +810,34 @@ export const eventFields: INodeProperties[] = [
description:
'Free text search terms to find events that match these terms in any field, except for extended properties',
},
{
displayName: 'Recurring Event Handling',
name: 'recurringEventHandling',
type: 'options',
default: 'expand',
options: [
{
name: 'All Occurrences',
value: 'expand',
description: 'Return all instances of recurring event for specified time range',
},
{
name: 'First Occurrence',
value: 'first',
description: 'Return event with specified recurrence rule',
},
{
name: 'Next Occurrence',
value: 'next',
description: 'Return next instance of recurring event',
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Show Deleted',
name: 'showDeleted',
@ -723,14 +853,7 @@ export const eventFields: INodeProperties[] = [
default: false,
description: 'Whether to include hidden invitations in the result',
},
{
displayName: 'Single Events',
name: 'singleEvents',
type: 'boolean',
default: false,
description:
'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
},
{
displayName: 'Timezone',
name: 'timeZone',
@ -797,6 +920,30 @@ export const eventFields: INodeProperties[] = [
},
default: '',
},
{
displayName: 'Modify',
name: 'modifyTarget',
type: 'options',
options: [
{
name: 'Recurring Event Instance',
value: 'instance',
},
{
name: 'Recurring Event',
value: 'event',
},
],
default: 'instance',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
resource: ['event'],
operation: ['update'],
eventId: [{ _cnd: { includes: '_' } }],
},
},
},
{
displayName: 'Use Default Reminders',
name: 'useDefaultReminders',

View file

@ -34,3 +34,8 @@ export interface IEvent {
visibility?: string;
conferenceData?: IConferenceData;
}
export type RecurringEventInstance = {
recurringEventId?: string;
start: { dateTime: string; date: string };
};

View file

@ -1,18 +1,22 @@
import { DateTime } from 'luxon';
import moment from 'moment-timezone';
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
INode,
INodeListSearchItems,
INodeListSearchResult,
IPollFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
import { RRule } from 'rrule';
import type { RecurringEventInstance } from './EventInterface';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
@ -50,7 +54,6 @@ export async function googleApiRequestAllItems(
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
@ -127,58 +130,75 @@ export async function getTimezones(
return { results };
}
type RecurentEvent = {
export type RecurrentEvent = {
start: {
dateTime: string;
timeZone: string;
date?: string;
dateTime?: string;
timeZone?: string;
};
end: {
dateTime: string;
timeZone: string;
date?: string;
dateTime?: string;
timeZone?: string;
};
recurrence: string[];
nextOccurrence?: {
start: {
dateTime: string;
timeZone: string;
timeZone?: string;
};
end: {
dateTime: string;
timeZone: string;
timeZone?: string;
};
};
};
export function addNextOccurrence(items: RecurentEvent[]) {
export function addNextOccurrence(items: RecurrentEvent[]) {
for (const item of items) {
if (item.recurrence) {
let eventRecurrence;
try {
eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE'));
if (!eventRecurrence) continue;
const rrule = RRule.fromString(eventRecurrence);
const start = moment(item.start.dateTime || item.end.date).utc();
const end = moment(item.end.dateTime || item.end.date).utc();
const rruleWithStartDate = `DTSTART:${start.format(
'YYYYMMDDTHHmmss',
)}Z\n${eventRecurrence}`;
const rrule = RRule.fromString(rruleWithStartDate);
const until = rrule.options?.until;
const now = new Date();
if (until && until < now) {
const now = moment().utc();
if (until && moment(until).isBefore(now)) {
continue;
}
const nextOccurrence = rrule.after(new Date());
const nextDate = rrule.after(now.toDate(), false);
item.nextOccurrence = {
start: {
dateTime: moment(nextOccurrence).format(),
timeZone: item.start.timeZone,
},
end: {
dateTime: moment(nextOccurrence)
.add(moment(item.end.dateTime).diff(moment(item.start.dateTime)))
.format(),
timeZone: item.end.timeZone,
},
};
if (nextDate) {
const nextStart = moment(nextDate);
const duration = moment.duration(moment(end).diff(moment(start)));
const nextEnd = moment(nextStart).add(duration);
item.nextOccurrence = {
start: {
dateTime: nextStart.format(),
timeZone: item.start.timeZone,
},
end: {
dateTime: nextEnd.format(),
timeZone: item.end.timeZone,
},
};
}
} catch (error) {
console.log(`Error adding next occurrence ${eventRecurrence}`);
}
@ -193,3 +213,92 @@ export function addTimezoneToDate(date: string, timezone: string) {
if (hasTimezone(date)) return date;
return moment.tz(date, timezone).utc().format();
}
async function requestWithRetries(
node: INode,
requestFn: () => Promise<any>,
retryCount: number = 0,
maxRetries: number = 10,
itemIndex: number = 0,
): Promise<any> {
try {
return await requestFn();
} catch (error) {
if (!(error instanceof NodeApiError)) {
throw new NodeOperationError(node, error.message, { itemIndex });
}
if (retryCount >= maxRetries) throw error;
if (error.httpCode === '403' || error.httpCode === '429') {
const delay = 1000 * Math.pow(2, retryCount);
console.log(`Rate limit hit. Retrying in ${delay}ms... (Attempt ${retryCount + 1})`);
await sleep(delay);
return await requestWithRetries(node, requestFn, retryCount + 1, maxRetries, itemIndex);
}
throw error;
}
}
export async function googleApiRequestWithRetries({
context,
method,
resource,
body = {},
qs = {},
uri,
headers = {},
itemIndex = 0,
}: {
context: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
method: IHttpRequestMethods;
resource: string;
body?: any;
qs?: IDataObject;
uri?: string;
headers?: IDataObject;
itemIndex?: number;
}) {
const requestFn = async (): Promise<any> => {
return await googleApiRequest.call(context, method, resource, body, qs, uri, headers);
};
const retryCount = 0;
const maxRetries = 10;
return await requestWithRetries(context.getNode(), requestFn, retryCount, maxRetries, itemIndex);
}
export const eventExtendYearIntoFuture = (
data: RecurringEventInstance[],
timezone: string,
currentYear?: number, // for testing purposes
) => {
const thisYear = currentYear || moment().tz(timezone).year();
return data.some((event) => {
if (!event.recurringEventId) return false;
const eventStart = event.start.dateTime || event.start.date;
const eventDateTime = moment(eventStart).tz(timezone);
if (!eventDateTime.isValid()) return false;
const targetYear = eventDateTime.year();
if (targetYear - thisYear >= 1) {
return true;
} else {
return false;
}
});
};
export function dateObjectToISO<T>(date: T): string {
if (date instanceof DateTime) return date.toISO();
if (date instanceof Date) return date.toISOString();
return date as string;
}

View file

@ -36,7 +36,7 @@
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
},
{
"label": "5 workflow automations for Mattermost that we love at n8n",
"label": "5 workflow automation for Mattermost that we love at n8n",
"icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
},

View file

@ -8,22 +8,33 @@ import type {
INodeType,
INodeTypeDescription,
JsonObject,
NodeExecutionHint,
} from 'n8n-workflow';
import {
NodeConnectionType,
NodeApiError,
NodeOperationError,
NodeExecutionOutput,
} from 'n8n-workflow';
import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription';
import { eventFields, eventOperations } from './EventDescription';
import type { IEvent } from './EventInterface';
import type { IEvent, RecurringEventInstance } from './EventInterface';
import {
addNextOccurrence,
addTimezoneToDate,
dateObjectToISO,
encodeURIComponentOnce,
eventExtendYearIntoFuture,
getCalendars,
getTimezones,
googleApiRequest,
googleApiRequestAllItems,
googleApiRequestWithRetries,
type RecurrentEvent,
} from './GenericFunctions';
import { sortItemKeysByPriorityList } from '../../../utils/utilities';
export class GoogleCalendar implements INodeType {
description: INodeTypeDescription = {
@ -31,7 +42,7 @@ export class GoogleCalendar implements INodeType {
name: 'googleCalendar',
icon: 'file:googleCalendar.svg',
group: ['input'],
version: [1, 1.1, 1.2],
version: [1, 1.1, 1.2, 1.3],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API',
defaults: {
@ -132,6 +143,7 @@ export class GoogleCalendar implements INodeType {
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
const hints: NodeExecutionHint[] = [];
let responseData;
const resource = this.getNodeParameter('resource', 0);
@ -148,8 +160,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = decodeURIComponent(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const timeMin = this.getNodeParameter('timeMin', i) as string;
const timeMax = this.getNodeParameter('timeMax', i) as string;
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
const options = this.getNodeParameter('options', i);
const outputFormat = options.outputFormat || 'availability';
const tz = this.getNodeParameter('options.timezone', i, '', {
@ -200,8 +212,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const start = this.getNodeParameter('start', i) as string;
const end = this.getNodeParameter('end', i) as string;
const start = dateObjectToISO(this.getNodeParameter('start', i));
const end = dateObjectToISO(this.getNodeParameter('end', i));
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i);
@ -379,16 +391,33 @@ export class GoogleCalendar implements INodeType {
if (tz) {
qs.timeZone = tz;
}
responseData = await googleApiRequest.call(
responseData = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
);
)) as IDataObject;
if (responseData) {
responseData = addNextOccurrence([responseData]);
if (nodeVersion >= 1.3 && options.returnNextInstance && responseData.recurrence) {
const eventInstances =
((
(await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${responseData.id}/instances`,
{},
{
timeMin: new Date().toISOString(),
maxResults: 1,
},
)) as IDataObject
).items as IDataObject[]) || [];
responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
} else {
responseData = addNextOccurrence([responseData as RecurrentEvent]);
}
}
}
//https://developers.google.com/calendar/v3/reference/events/list
@ -401,6 +430,22 @@ export class GoogleCalendar implements INodeType {
const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true,
}) as string;
if (nodeVersion >= 1.3) {
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
if (timeMin) {
qs.timeMin = addTimezoneToDate(timeMin as string, tz || timezone);
}
if (timeMax) {
qs.timeMax = addTimezoneToDate(timeMax as string, tz || timezone);
}
if (!options.recurringEventHandling || options.recurringEventHandling === 'expand') {
qs.singleEvents = true;
}
}
if (options.iCalUID) {
qs.iCalUID = options.iCalUID as string;
}
@ -423,16 +468,19 @@ export class GoogleCalendar implements INodeType {
qs.singleEvents = options.singleEvents as boolean;
}
if (options.timeMax) {
qs.timeMax = addTimezoneToDate(options.timeMax as string, tz || timezone);
qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone);
}
if (options.timeMin) {
qs.timeMin = addTimezoneToDate(options.timeMin as string, tz || timezone);
qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone);
}
if (tz) {
qs.timeZone = tz;
}
if (options.updatedMin) {
qs.updatedMin = addTimezoneToDate(options.updatedMin as string, tz || timezone);
qs.updatedMin = addTimezoneToDate(
dateObjectToISO(options.updatedMin),
tz || timezone,
);
}
if (options.fields) {
qs.fields = options.fields as string;
@ -460,7 +508,76 @@ export class GoogleCalendar implements INodeType {
}
if (responseData) {
responseData = addNextOccurrence(responseData);
if (nodeVersion >= 1.3 && options.recurringEventHandling === 'next') {
const updatedEvents: IDataObject[] = [];
for (const event of responseData) {
if (event.recurrence) {
const eventInstances =
((
(await googleApiRequestWithRetries({
context: this,
method: 'GET',
resource: `/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
qs: {
timeMin: new Date().toISOString(),
maxResults: 1,
},
itemIndex: i,
})) as IDataObject
).items as IDataObject[]) || [];
updatedEvents.push(eventInstances[0] || event);
continue;
}
updatedEvents.push(event);
}
responseData = updatedEvents;
} else if (nodeVersion >= 1.3 && options.recurringEventHandling === 'first') {
responseData = responseData.filter((event: IDataObject) => {
if (
qs.timeMin &&
event.recurrence &&
event.created &&
event.created < qs.timeMin
) {
return false;
}
if (
qs.timeMax &&
event.recurrence &&
event.created &&
event.created > qs.timeMax
) {
return false;
}
return true;
});
} else if (nodeVersion < 1.3) {
// in node version above or equal to 1.3, this would correspond to the 'expand' option,
// so no need to add the next occurrence as event instances returned by the API
responseData = addNextOccurrence(responseData);
}
if (
!qs.timeMax &&
(!options.recurringEventHandling || options.recurringEventHandling === 'expand')
) {
const suggestTrim = eventExtendYearIntoFuture(
responseData as RecurringEventInstance[],
timezone,
);
if (suggestTrim) {
hints.push({
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
});
}
}
}
}
//https://developers.google.com/calendar/v3/reference/events/patch
@ -468,7 +585,22 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const eventId = this.getNodeParameter('eventId', i) as string;
let eventId = this.getNodeParameter('eventId', i) as string;
if (nodeVersion >= 1.3) {
const modifyTarget = this.getNodeParameter('modifyTarget', i, 'instance') as string;
if (modifyTarget === 'event') {
const instance = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
eventId = instance.recurringEventId as string;
}
}
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i);
let updateTimezone = updateFields.timezone as string;
@ -658,6 +790,30 @@ export class GoogleCalendar implements INodeType {
}
}
}
return [returnData];
const keysPriorityList = [
'id',
'summary',
'start',
'end',
'attendees',
'creator',
'organizer',
'description',
'location',
'created',
'updated',
];
let nodeExecutionData = returnData;
if (nodeVersion >= 1.3) {
nodeExecutionData = sortItemKeysByPriorityList(returnData, keysPriorityList);
}
if (hints.length) {
return new NodeExecutionOutput([nodeExecutionData], hints);
}
return [nodeExecutionData];
}
}

View file

@ -1,4 +1,7 @@
import { addTimezoneToDate } from '../GenericFunctions';
import { DateTime } from 'luxon';
import type { RecurringEventInstance } from '../EventInterface';
import { addTimezoneToDate, dateObjectToISO, eventExtendYearIntoFuture } from '../GenericFunctions';
describe('addTimezoneToDate', () => {
it('should add timezone to date', () => {
@ -18,3 +21,87 @@ describe('addTimezoneToDate', () => {
expect(result4).toBe('2021-09-01T12:00:00.000+08:00');
});
});
describe('dateObjectToISO', () => {
test('should return ISO string for DateTime instance', () => {
const mockDateTime = DateTime.fromISO('2025-01-07T12:00:00');
const result = dateObjectToISO(mockDateTime);
expect(result).toBe('2025-01-07T12:00:00.000+00:00');
});
test('should return ISO string for Date instance', () => {
const mockDate = new Date('2025-01-07T12:00:00Z');
const result = dateObjectToISO(mockDate);
expect(result).toBe('2025-01-07T12:00:00.000Z');
});
test('should return string when input is not a DateTime or Date instance', () => {
const inputString = '2025-01-07T12:00:00';
const result = dateObjectToISO(inputString);
expect(result).toBe(inputString);
});
});
describe('eventExtendYearIntoFuture', () => {
const timezone = 'UTC';
it('should return true if any event extends into the next year', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: '2026-01-01T00:00:00Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(true);
});
it('should return false if no event extends into the next year', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: '2025-12-31T23:59:59Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should return false for invalid event start dates', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: 'invalid-date', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should return false for events without a recurringEventId', () => {
const events = [
{
recurringEventId: null,
start: { dateTime: '2025-01-01T00:00:00Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should handle events with only a date and no time', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: null, date: '2026-01-01' },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(true);
});
});

View file

@ -0,0 +1,89 @@
import moment from 'moment-timezone';
import type { RecurrentEvent } from '../GenericFunctions';
import { addNextOccurrence } from '../GenericFunctions';
const mockNow = '2024-09-06T16:30:00+03:00';
jest.spyOn(global.Date, 'now').mockImplementation(() => moment(mockNow).valueOf());
describe('addNextOccurrence', () => {
it('should not modify event if no recurrence exists', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: [],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle event with no RRULE correctly', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['FREQ=WEEKLY;COUNT=2'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should ignore recurrence if until date is in the past', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-08-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-08-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['RRULE:FREQ=DAILY;UNTIL=20240805T000000Z'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle errors gracefully without breaking and return unchanged event', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-06T17:30:00+03:00',
timeZone: 'Europe/Berlin',
},
end: {
dateTime: '2024-09-06T18:00:00+03:00',
timeZone: 'Europe/Berlin',
},
recurrence: ['xxxxx'],
},
];
const result = addNextOccurrence(event);
expect(result).toEqual(event);
});
});

View file

@ -0,0 +1,221 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions, IDataObject, NodeExecutionOutput } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
let response: IDataObject[] | undefined = [];
let responseWithRetries: IDataObject | undefined = {};
jest.mock('../../GenericFunctions', () => {
const originalModule = jest.requireActual('../../GenericFunctions');
return {
...originalModule,
getTimezones: jest.fn(),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(async function () {
return (() => response)();
}),
googleApiRequestWithRetries: jest.fn(async function () {
return (() => responseWithRetries)();
}),
addNextOccurrence: jest.fn(function (data: IDataObject[]) {
return data;
}),
};
});
describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
googleCalendar = new GoogleCalendar();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn(),
getNode: jest.fn(),
getNodeParameter: jest.fn(),
getTimezone: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([]),
},
});
response = undefined;
responseWithRetries = undefined;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Google Calendar > Event > Get Many', () => {
it('should configure get all request parameters in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
query: 'test query',
recurringEventHandling: 'expand',
showDeleted: true,
showHiddenInvitations: true,
updatedMin: '2024-12-21T00:00:00',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
q: 'test query',
showDeleted: true,
showHiddenInvitations: true,
singleEvents: true,
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
updatedMin: '2024-12-20T23:00:00Z',
},
);
});
it('should configure get all recurringEventHandling equals next in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'next',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
},
];
responseWithRetries = { items: [] };
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
},
);
expect(genericFunctions.googleApiRequestWithRetries).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
itemIndex: 0,
resource: '/calendar/v3/calendars/myCalendar/events/undefined/instances',
}),
);
expect(result).toEqual([[]]);
});
it('should configure get all recurringEventHandling equals first in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'first',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-19T00:00:00',
},
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-27T00:00:00',
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
it('should configure get all should have hint in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(''); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-25T00:00:00',
recurringEventId: '1',
start: { dateTime: '2027-12-25T00:00:00' },
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect((result as NodeExecutionOutput).getHints()).toEqual([
{
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
},
]);
});
});
});

View file

@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({
encodeURIComponentOnce: jest.fn(),
}));
describe('RespondToWebhook Node', () => {
describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;

View file

@ -1,3 +1,5 @@
import type { INodeExecutionData } from 'n8n-workflow';
import {
compareItems,
flattenKeys,
@ -5,6 +7,7 @@ import {
getResolvables,
keysToLowercase,
shuffleArray,
sortItemKeysByPriorityList,
wrapData,
} from '@utils/utilities';
@ -252,3 +255,60 @@ describe('compareItems', () => {
expect(result).toBe(true);
});
});
describe('sortItemKeysByPriorityList', () => {
it('should reorder keys based on priority list', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c']);
});
it('should sort keys not in the priority list alphabetically', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2, d: 4 } }];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c', 'd']);
});
it('should sort all keys alphabetically when priority list is empty', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
const priorityList: string[] = [];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'c']);
});
it('should handle an empty data array', () => {
const data: INodeExecutionData[] = [];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
// Expect an empty array since there is no data
expect(result).toEqual([]);
});
it('should handle a single object in the data array', () => {
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
const priorityList = ['a', 'b', 'c'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
});
it('should handle duplicate keys in the priority list gracefully', () => {
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
const priorityList = ['a', 'b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
});
});

View file

@ -427,3 +427,37 @@ export function escapeHtml(text: string): string {
}
});
}
/**
* Sorts each item json's keys by a priority list
*
* @param {INodeExecutionData[]} data The array of items which keys will be sorted
* @param {string[]} priorityList The priority list, keys of item.json will be sorted in this order first then alphabetically
*/
export function sortItemKeysByPriorityList(data: INodeExecutionData[], priorityList: string[]) {
return data.map((item) => {
const itemKeys = Object.keys(item.json);
const updatedKeysOrder = itemKeys.sort((a, b) => {
const indexA = priorityList.indexOf(a);
const indexB = priorityList.indexOf(b);
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
} else if (indexA !== -1) {
return -1;
} else if (indexB !== -1) {
return 1;
}
return a.localeCompare(b);
});
const updatedItem: IDataObject = {};
for (const key of updatedKeysOrder) {
updatedItem[key] = item.json[key];
}
item.json = updatedItem;
return item;
});
}

View file

@ -1404,7 +1404,7 @@ function addToIssuesIfMissing(
if (
(nodeProperties.type === 'string' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) ||
(nodeProperties.type === 'dateTime' && value === undefined) ||
(nodeProperties.type === 'dateTime' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'options' && (value === '' || value === undefined)) ||
((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') &&
!isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator))

View file

@ -4195,4 +4195,57 @@ describe('NodeHelpers', () => {
});
}
});
describe('getParameterIssues, required parameters validation', () => {
const testNode: INode = {
id: '12345',
name: 'Test Node',
typeVersion: 1,
type: 'n8n-nodes-base.testNode',
position: [1, 1],
parameters: {},
};
it('Should validate required dateTime parameters if empty string', () => {
const nodeProperties: INodeProperties = {
displayName: 'Date Time',
name: 'testDateTime',
type: 'dateTime',
default: '',
required: true,
};
const nodeValues: INodeParameters = {
testDateTime: '',
};
const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
expect(result).toEqual({
parameters: {
testDateTime: ['Parameter "Date Time" is required.'],
},
});
});
it('Should validate required dateTime parameters if empty undefined', () => {
const nodeProperties: INodeProperties = {
displayName: 'Date Time',
name: 'testDateTime',
type: 'dateTime',
default: '',
required: true,
};
const nodeValues: INodeParameters = {
testDateTime: undefined,
};
const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
expect(result).toEqual({
parameters: {
testDateTime: ['Parameter "Date Time" is required.'],
},
});
});
});
});

21
patches/bull@4.12.1.patch Normal file
View file

@ -0,0 +1,21 @@
diff --git a/lib/job.js b/lib/job.js
index 6a3606974fd3e397c6c5b2b6e65b20670c68f753..4cdbed1d564ceeb5a80c92eb605e49cfd3c8ccdd 100644
--- a/lib/job.js
+++ b/lib/job.js
@@ -511,9 +511,14 @@ Job.prototype.finished = async function() {
}
};
- const onFailed = (jobId, failedReason) => {
+ const onFailed = async (jobId, failedReason) => {
if (String(jobId) === String(this.id)) {
- reject(new Error(failedReason));
+ const job = await Job.fromId(this.queue, this.id);
+ const error = new Error(failedReason);
+ if (job && job.stacktrace && job.stacktrace.length > 0) {
+ error.stack = job.stacktrace.join('\n');
+ }
+ reject(error);
removeListeners();
}
};

View file

@ -130,6 +130,9 @@ patchedDependencies:
'@types/ws@8.5.4':
hash: nbzuqaoyqbrfwipijj5qriqqju
path: patches/@types__ws@8.5.4.patch
bull@4.12.1:
hash: ep6h4rqtpclldfcdohxlgcb3aq
path: patches/bull@4.12.1.patch
pkce-challenge@3.0.0:
hash: dypouzb3lve7vncq25i5fuanki
path: patches/pkce-challenge@3.0.0.patch
@ -822,7 +825,7 @@ importers:
version: 2.4.3
bull:
specifier: 4.12.1
version: 4.12.1
version: 4.12.1(patch_hash=ep6h4rqtpclldfcdohxlgcb3aq)
cache-manager:
specifier: 5.2.3
version: 5.2.3
@ -19827,7 +19830,7 @@ snapshots:
- supports-color
optional: true
bull@4.12.1:
bull@4.12.1(patch_hash=ep6h4rqtpclldfcdohxlgcb3aq):
dependencies:
cron-parser: 4.9.0
get-port: 5.1.1