mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into pay-1852-public-api-delete-users-from-project
This commit is contained in:
commit
79d347fb23
12
.github/workflows/docker-base-image.yml
vendored
12
.github/workflows/docker-base-image.yml
vendored
|
@ -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
|
||||
|
|
10
.github/workflows/docker-images-benchmark.yml
vendored
10
.github/workflows/docker-images-benchmark.yml
vendored
|
@ -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
|
||||
|
|
83
.github/workflows/docker-images-custom.yml
vendored
Normal file
83
.github/workflows/docker-images-custom.yml
vendored
Normal 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
|
86
.github/workflows/docker-images-nightly.yml
vendored
86
.github/workflows/docker-images-nightly.yml
vendored
|
@ -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
|
||||
|
|
12
.github/workflows/release-publish.yml
vendored
12
.github/workflows/release-publish.yml
vendored
|
@ -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: |
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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', () => {
|
||||
|
|
62
packages/@n8n/task-runner/src/task-runner-sentry.ts
Normal file
62
packages/@n8n/task-runner/src/task-runner-sentry.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -10,6 +10,8 @@ describe('SourceControlService', () => {
|
|||
Container.get(InstanceSettings),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
const sourceControlService = new SourceControlService(
|
||||
mock(),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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(
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -44,7 +44,7 @@ const validateResourceMapperValue = (
|
|||
!skipRequiredCheck &&
|
||||
schemaEntry?.required === true &&
|
||||
schemaEntry.type !== 'boolean' &&
|
||||
!resolvedValue
|
||||
(resolvedValue === undefined || resolvedValue === null)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ export async function getAllCredentials(
|
|||
): Promise<ICredentialsResponse[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
||||
...(includeScopes ? { includeScopes } : {}),
|
||||
includeData: true,
|
||||
...(filter ? { filter } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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({});
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -34,3 +34,8 @@ export interface IEvent {
|
|||
visibility?: string;
|
||||
conferenceData?: IConferenceData;
|
||||
}
|
||||
|
||||
export type RecurringEventInstance = {
|
||||
recurringEventId?: string;
|
||||
start: { dateTime: string; date: string };
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/"
|
||||
},
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({
|
|||
encodeURIComponentOnce: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RespondToWebhook Node', () => {
|
||||
describe('Google Calendar Node', () => {
|
||||
let googleCalendar: GoogleCalendar;
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
21
patches/bull@4.12.1.patch
Normal 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();
|
||||
}
|
||||
};
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue